+
{
- const accountId = rootState.route?.params?.accountId;
- if (!accountId) return false;
+ isRTL: ($state, _getters, rootState, rootGetters) => {
+ const accountId = Number(rootState.route?.params?.accountId);
+ const userLocale = rootGetters?.getUISettings?.locale;
+ const accountLocale =
+ accountId && findRecordById($state, accountId)?.locale;
- const { locale } = findRecordById($state, Number(accountId));
- return locale ? getLanguageDirection(locale) : false;
+ // Prefer user locale; fallback to account locale
+ const effectiveLocale = userLocale ?? accountLocale;
+
+ return effectiveLocale ? getLanguageDirection(effectiveLocale) : false;
},
isTrialAccount: $state => id => {
const account = findRecordById($state, id);
diff --git a/app/javascript/dashboard/store/modules/agentCapacityPolicies.js b/app/javascript/dashboard/store/modules/agentCapacityPolicies.js
new file mode 100644
index 000000000..ea554448f
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/agentCapacityPolicies.js
@@ -0,0 +1,316 @@
+import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
+import types from '../mutation-types';
+import AgentCapacityPoliciesAPI from '../../api/agentCapacityPolicies';
+import { throwErrorMessage } from '../utils/api';
+import camelcaseKeys from 'camelcase-keys';
+import snakecaseKeys from 'snakecase-keys';
+
+export const state = {
+ records: [],
+ uiFlags: {
+ isFetching: false,
+ isFetchingItem: false,
+ isCreating: false,
+ isUpdating: false,
+ isDeleting: false,
+ },
+ usersUiFlags: {
+ isFetching: false,
+ isDeleting: false,
+ },
+};
+
+export const getters = {
+ getAgentCapacityPolicies(_state) {
+ return _state.records;
+ },
+ getUIFlags(_state) {
+ return _state.uiFlags;
+ },
+ getUsersUIFlags(_state) {
+ return _state.usersUiFlags;
+ },
+ getAgentCapacityPolicyById: _state => id => {
+ return _state.records.find(record => record.id === Number(id)) || {};
+ },
+};
+
+export const actions = {
+ get: async function get({ commit }) {
+ 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, { deep: true })
+ );
+ } catch (error) {
+ throwErrorMessage(error);
+ } finally {
+ commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetching: false });
+ }
+ },
+
+ show: async function show({ commit }, policyId) {
+ commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetchingItem: true });
+ try {
+ const response = await AgentCapacityPoliciesAPI.show(policyId);
+ const policy = camelcaseKeys(response.data, { deep: true });
+ commit(types.SET_AGENT_CAPACITY_POLICY, policy);
+ } catch (error) {
+ throwErrorMessage(error);
+ } finally {
+ commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, {
+ isFetchingItem: false,
+ });
+ }
+ },
+
+ create: async function create({ commit }, policyObj) {
+ commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: true });
+ try {
+ const response = await AgentCapacityPoliciesAPI.create(
+ snakecaseKeys(policyObj)
+ );
+ commit(
+ types.ADD_AGENT_CAPACITY_POLICY,
+ camelcaseKeys(response.data, { deep: true })
+ );
+ return response.data;
+ } catch (error) {
+ throwErrorMessage(error);
+ throw error;
+ } finally {
+ commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: false });
+ }
+ },
+
+ update: async function update({ commit }, { id, ...policyParams }) {
+ commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: true });
+ try {
+ const response = await AgentCapacityPoliciesAPI.update(
+ id,
+ snakecaseKeys(policyParams)
+ );
+ commit(
+ types.EDIT_AGENT_CAPACITY_POLICY,
+ camelcaseKeys(response.data, { deep: true })
+ );
+ return response.data;
+ } catch (error) {
+ throwErrorMessage(error);
+ throw error;
+ } finally {
+ commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: false });
+ }
+ },
+
+ delete: async function deletePolicy({ commit }, policyId) {
+ commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isDeleting: true });
+ try {
+ await AgentCapacityPoliciesAPI.delete(policyId);
+ commit(types.DELETE_AGENT_CAPACITY_POLICY, policyId);
+ } catch (error) {
+ throwErrorMessage(error);
+ throw error;
+ } finally {
+ commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isDeleting: false });
+ }
+ },
+
+ getUsers: async function getUsers({ commit }, policyId) {
+ commit(types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, {
+ isFetching: true,
+ });
+ try {
+ const response = await AgentCapacityPoliciesAPI.getUsers(policyId);
+ commit(types.SET_AGENT_CAPACITY_POLICIES_USERS, {
+ policyId,
+ users: camelcaseKeys(response.data),
+ });
+ return response.data;
+ } catch (error) {
+ throwErrorMessage(error);
+ throw error;
+ } finally {
+ commit(types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, {
+ isFetching: false,
+ });
+ }
+ },
+
+ 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 = {
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG](_state, data) {
+ _state.uiFlags = {
+ ..._state.uiFlags,
+ ...data,
+ };
+ },
+
+ [types.SET_AGENT_CAPACITY_POLICIES]: MutationHelpers.set,
+ [types.SET_AGENT_CAPACITY_POLICY]: MutationHelpers.setSingleRecord,
+ [types.ADD_AGENT_CAPACITY_POLICY]: MutationHelpers.create,
+ [types.EDIT_AGENT_CAPACITY_POLICY]: MutationHelpers.updateAttributes,
+ [types.DELETE_AGENT_CAPACITY_POLICY]: MutationHelpers.destroy,
+
+ [types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG](_state, data) {
+ _state.usersUiFlags = {
+ ..._state.usersUiFlags,
+ ...data,
+ };
+ },
+ [types.SET_AGENT_CAPACITY_POLICIES_USERS](_state, { policyId, users }) {
+ const policy = _state.records.find(p => p.id === policyId);
+ if (policy) {
+ 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 {
+ namespaced: true,
+ state,
+ getters,
+ actions,
+ mutations,
+};
diff --git a/app/javascript/dashboard/store/modules/assignmentPolicies.js b/app/javascript/dashboard/store/modules/assignmentPolicies.js
new file mode 100644
index 000000000..9ea2f49c3
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/assignmentPolicies.js
@@ -0,0 +1,232 @@
+import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
+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: [],
+ uiFlags: {
+ isFetching: false,
+ isFetchingItem: false,
+ isCreating: false,
+ isUpdating: false,
+ isDeleting: false,
+ },
+ inboxUiFlags: {
+ isFetching: false,
+ isDeleting: false,
+ },
+};
+
+export const getters = {
+ getAssignmentPolicies(_state) {
+ return _state.records;
+ },
+ getUIFlags(_state) {
+ return _state.uiFlags;
+ },
+ getInboxUiFlags(_state) {
+ return _state.inboxUiFlags;
+ },
+ getAssignmentPolicyById: _state => id => {
+ return _state.records.find(record => record.id === Number(id)) || {};
+ },
+};
+
+export const actions = {
+ get: async function get({ commit }) {
+ commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: true });
+ try {
+ const response = await AssignmentPoliciesAPI.get();
+ commit(types.SET_ASSIGNMENT_POLICIES, camelcaseKeys(response.data));
+ } catch (error) {
+ throwErrorMessage(error);
+ } finally {
+ commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: false });
+ }
+ },
+
+ show: async function show({ commit }, policyId) {
+ commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: true });
+ try {
+ const response = await AssignmentPoliciesAPI.show(policyId);
+ const policy = camelcaseKeys(response.data);
+ commit(types.SET_ASSIGNMENT_POLICY, policy);
+ } catch (error) {
+ throwErrorMessage(error);
+ } finally {
+ commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: false });
+ }
+ },
+
+ create: async function create({ commit }, policyObj) {
+ commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: true });
+ try {
+ const response = await AssignmentPoliciesAPI.create(
+ snakecaseKeys(policyObj)
+ );
+ commit(types.ADD_ASSIGNMENT_POLICY, camelcaseKeys(response.data));
+ return response.data;
+ } catch (error) {
+ throwErrorMessage(error);
+ throw error;
+ } finally {
+ commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: false });
+ }
+ },
+
+ update: async function update({ commit }, { id, ...policyParams }) {
+ commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: true });
+ try {
+ const response = await AssignmentPoliciesAPI.update(
+ id,
+ snakecaseKeys(policyParams)
+ );
+ commit(types.EDIT_ASSIGNMENT_POLICY, camelcaseKeys(response.data));
+ return response.data;
+ } catch (error) {
+ throwErrorMessage(error);
+ throw error;
+ } finally {
+ commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: false });
+ }
+ },
+
+ delete: async function deletePolicy({ commit }, policyId) {
+ commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: true });
+ try {
+ await AssignmentPoliciesAPI.delete(policyId);
+ commit(types.DELETE_ASSIGNMENT_POLICY, policyId);
+ } catch (error) {
+ throwErrorMessage(error);
+ throw error;
+ } finally {
+ commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: false });
+ }
+ },
+
+ getInboxes: async function getInboxes({ commit }, policyId) {
+ commit(types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: true });
+ try {
+ const response = await AssignmentPoliciesAPI.getInboxes(policyId);
+ commit(types.SET_ASSIGNMENT_POLICIES_INBOXES, {
+ policyId,
+ inboxes: camelcaseKeys(response.data.inboxes),
+ });
+ } catch (error) {
+ throwErrorMessage(error);
+ throw error;
+ } finally {
+ commit(types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, {
+ isFetching: false,
+ });
+ }
+ },
+
+ 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 = {
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG](_state, data) {
+ _state.uiFlags = {
+ ..._state.uiFlags,
+ ...data,
+ };
+ },
+
+ [types.SET_ASSIGNMENT_POLICIES]: MutationHelpers.set,
+ [types.SET_ASSIGNMENT_POLICY]: MutationHelpers.setSingleRecord,
+ [types.ADD_ASSIGNMENT_POLICY]: MutationHelpers.create,
+ [types.EDIT_ASSIGNMENT_POLICY]: MutationHelpers.updateAttributes,
+ [types.DELETE_ASSIGNMENT_POLICY]: MutationHelpers.destroy,
+
+ [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG](_state, data) {
+ _state.inboxUiFlags = {
+ ..._state.inboxUiFlags,
+ ...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 {
+ namespaced: true,
+ state,
+ getters,
+ actions,
+ mutations,
+};
diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
index 3f1c32059..3d627e3ef 100644
--- a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
+++ b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
@@ -64,11 +64,13 @@ const getValueFromConversation = (conversation, attributeKey) => {
switch (attributeKey) {
case 'status':
case 'priority':
- case 'display_id':
case 'labels':
case 'created_at':
case 'last_activity_at':
return conversation[attributeKey];
+ case 'display_id':
+ // Frontend uses 'id' but backend expects 'display_id'
+ return conversation.display_id || conversation.id;
case 'assignee_id':
return conversation.meta?.assignee?.id;
case 'inbox_id':
diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
index b36128819..096481c69 100644
--- a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
+++ b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
@@ -247,7 +247,7 @@ describe('filterHelpers', () => {
// Text search tests - display_id
it('should match conversation with equal_to operator for display_id', () => {
- const conversation = { display_id: '12345' };
+ const conversation = { id: '12345' };
const filters = [
{
attribute_key: 'display_id',
@@ -260,7 +260,7 @@ describe('filterHelpers', () => {
});
it('should match conversation with contains operator for display_id', () => {
- const conversation = { display_id: '12345' };
+ const conversation = { id: '12345' };
const filters = [
{
attribute_key: 'display_id',
@@ -273,7 +273,7 @@ describe('filterHelpers', () => {
});
it('should not match conversation with does_not_contain operator for display_id', () => {
- const conversation = { display_id: '12345' };
+ const conversation = { id: '12345' };
const filters = [
{
attribute_key: 'display_id',
@@ -286,7 +286,7 @@ describe('filterHelpers', () => {
});
it('should match conversation with does_not_contain operator when value is not present', () => {
- const conversation = { display_id: '12345' };
+ const conversation = { id: '12345' };
const filters = [
{
attribute_key: 'display_id',
@@ -989,7 +989,7 @@ describe('filterHelpers', () => {
it('should handle empty string values in conversation', () => {
const conversation = {
- display_id: '',
+ id: '',
};
const filters = [
{
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index c4789a7a9..9886be679 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -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)
diff --git a/app/javascript/dashboard/store/modules/notifications/getters.js b/app/javascript/dashboard/store/modules/notifications/getters.js
index 4f4cafc9a..8af1d0360 100644
--- a/app/javascript/dashboard/store/modules/notifications/getters.js
+++ b/app/javascript/dashboard/store/modules/notifications/getters.js
@@ -38,4 +38,7 @@ export const getters = {
getHasUnreadNotifications: $state => {
return $state.meta.unreadCount > 0;
},
+ getUnreadCount: $state => {
+ return $state.meta.unreadCount;
+ },
};
diff --git a/app/javascript/dashboard/store/modules/specs/account/getters.spec.js b/app/javascript/dashboard/store/modules/specs/account/getters.spec.js
index 77cc9b357..f354faca2 100644
--- a/app/javascript/dashboard/store/modules/specs/account/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/account/getters.spec.js
@@ -49,35 +49,74 @@ describe('#getters', () => {
});
describe('isRTL', () => {
- it('returns false when accountId is not present', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('returns false when accountId is not present and userLocale is not set', () => {
+ const state = { records: [accountData] };
const rootState = { route: { params: {} } };
- expect(getters.isRTL({}, null, rootState)).toBe(false);
+ const rootGetters = {};
+
+ expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false);
});
- it('returns true for RTL language', () => {
- const state = {
- records: [{ id: 1, locale: 'ar' }],
- };
- const rootState = { route: { params: { accountId: '1' } } };
- vi.spyOn(languageHelpers, 'getLanguageDirection').mockReturnValue(true);
- expect(getters.isRTL(state, null, rootState)).toBe(true);
+ it('uses userLocale when present (no accountId)', () => {
+ const state = { records: [accountData] };
+ const rootState = { route: { params: {} } };
+ const rootGetters = { getUISettings: { locale: 'ar' } };
+ const spy = vi
+ .spyOn(languageHelpers, 'getLanguageDirection')
+ .mockReturnValue(true);
+
+ expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true);
+ expect(spy).toHaveBeenCalledWith('ar');
});
- it('returns false for LTR language', () => {
- const state = {
- records: [{ id: 1, locale: 'en' }],
- };
+ it('prefers userLocale over account locale when both are present', () => {
+ const state = { records: [{ id: 1, locale: 'en' }] };
const rootState = { route: { params: { accountId: '1' } } };
- vi.spyOn(languageHelpers, 'getLanguageDirection').mockReturnValue(false);
- expect(getters.isRTL(state, null, rootState)).toBe(false);
+ const rootGetters = { getUISettings: { locale: 'ar' } };
+ const spy = vi
+ .spyOn(languageHelpers, 'getLanguageDirection')
+ .mockReturnValue(true);
+
+ expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true);
+ expect(spy).toHaveBeenCalledWith('ar');
});
- it('returns false when account is not found', () => {
- const state = {
- records: [],
- };
+ it('falls back to account locale when userLocale is not provided', () => {
+ const state = { records: [{ id: 1, locale: 'ar' }] };
const rootState = { route: { params: { accountId: '1' } } };
- expect(getters.isRTL(state, null, rootState)).toBe(false);
+ const rootGetters = {};
+ const spy = vi
+ .spyOn(languageHelpers, 'getLanguageDirection')
+ .mockReturnValue(true);
+
+ expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true);
+ expect(spy).toHaveBeenCalledWith('ar');
+ });
+
+ it('returns false for LTR language when userLocale is provided', () => {
+ const state = { records: [{ id: 1, locale: 'en' }] };
+ const rootState = { route: { params: { accountId: '1' } } };
+ const rootGetters = { getUISettings: { locale: 'en' } };
+ const spy = vi
+ .spyOn(languageHelpers, 'getLanguageDirection')
+ .mockReturnValue(false);
+
+ expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false);
+ expect(spy).toHaveBeenCalledWith('en');
+ });
+
+ it('returns false when accountId present but user locale is null', () => {
+ const state = { records: [{ id: 1, locale: 'en' }] };
+ const rootState = { route: { params: { accountId: '1' } } };
+ const rootGetters = { getUISettings: { locale: null } };
+ const spy = vi.spyOn(languageHelpers, 'getLanguageDirection');
+
+ expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false);
+ expect(spy).toHaveBeenCalledWith('en');
});
});
});
diff --git a/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/actions.spec.js b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/actions.spec.js
new file mode 100644
index 000000000..3414a2086
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/actions.spec.js
@@ -0,0 +1,408 @@
+import axios from 'axios';
+import { actions } from '../../agentCapacityPolicies';
+import types from '../../../mutation-types';
+import agentCapacityPoliciesList, {
+ camelCaseFixtures,
+ mockUsers,
+ mockInboxLimits,
+ camelCaseMockInboxLimits,
+} 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', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('#get', () => {
+ it('sends correct actions if API is success', async () => {
+ axios.get.mockResolvedValue({ data: agentCapacityPoliciesList });
+ camelcaseKeys.mockReturnValue(camelCaseFixtures);
+
+ await actions.get({ commit });
+
+ 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],
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetching: false }],
+ ]);
+ });
+
+ it('sends correct actions if API is error', async () => {
+ axios.get.mockRejectedValue({ message: 'Incorrect header' });
+
+ await actions.get({ commit });
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetching: true }],
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetching: false }],
+ ]);
+ });
+ });
+
+ describe('#show', () => {
+ it('sends correct actions if API is success', async () => {
+ const policyData = agentCapacityPoliciesList[0];
+ const camelCasedPolicy = camelCaseFixtures[0];
+
+ axios.get.mockResolvedValue({ data: policyData });
+ camelcaseKeys.mockReturnValue(camelCasedPolicy);
+
+ await actions.show({ commit }, 1);
+
+ 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],
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetchingItem: false }],
+ ]);
+ });
+
+ it('sends correct actions if API is error', async () => {
+ axios.get.mockRejectedValue({ message: 'Not found' });
+
+ await actions.show({ commit }, 1);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetchingItem: true }],
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetchingItem: false }],
+ ]);
+ });
+ });
+
+ describe('#create', () => {
+ it('sends correct actions if API is success', async () => {
+ const newPolicy = agentCapacityPoliciesList[0];
+ const camelCasedData = camelCaseFixtures[0];
+ const snakeCasedPolicy = { default_capacity: 10 };
+
+ 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, {
+ deep: true,
+ });
+ expect(commit.mock.calls).toEqual([
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: true }],
+ [types.ADD_AGENT_CAPACITY_POLICY, camelCasedData],
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: false }],
+ ]);
+ expect(result).toEqual(newPolicy);
+ });
+
+ it('sends correct actions if API is error', async () => {
+ axios.post.mockRejectedValue(new Error('Validation error'));
+
+ await expect(actions.create({ commit }, {})).rejects.toThrow(Error);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: true }],
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: false }],
+ ]);
+ });
+ });
+
+ describe('#update', () => {
+ it('sends correct actions if API is success', async () => {
+ const updateParams = { id: 1, name: 'Updated Policy' };
+ const responseData = {
+ ...agentCapacityPoliciesList[0],
+ name: 'Updated Policy',
+ };
+ const camelCasedData = {
+ ...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, {
+ deep: true,
+ });
+ expect(commit.mock.calls).toEqual([
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: true }],
+ [types.EDIT_AGENT_CAPACITY_POLICY, camelCasedData],
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: false }],
+ ]);
+ expect(result).toEqual(responseData);
+ });
+
+ it('sends correct actions if API is error', async () => {
+ axios.patch.mockRejectedValue(new Error('Validation error'));
+
+ await expect(
+ actions.update({ commit }, { id: 1, name: 'Test' })
+ ).rejects.toThrow(Error);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: true }],
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: false }],
+ ]);
+ });
+ });
+
+ describe('#delete', () => {
+ it('sends correct actions if API is success', async () => {
+ const policyId = 1;
+ axios.delete.mockResolvedValue({});
+
+ await actions.delete({ commit }, policyId);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isDeleting: true }],
+ [types.DELETE_AGENT_CAPACITY_POLICY, policyId],
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isDeleting: false }],
+ ]);
+ });
+
+ it('sends correct actions if API is error', async () => {
+ axios.delete.mockRejectedValue(new Error('Not found'));
+
+ await expect(actions.delete({ commit }, 1)).rejects.toThrow(Error);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isDeleting: true }],
+ [types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isDeleting: false }],
+ ]);
+ });
+ });
+
+ describe('#getUsers', () => {
+ it('sends correct actions if API is success', async () => {
+ const policyId = 1;
+ const userData = [
+ { id: 1, name: 'Agent 1', email: 'agent1@example.com', capacity: 15 },
+ { id: 2, name: 'Agent 2', email: 'agent2@example.com', capacity: 20 },
+ ];
+ const camelCasedUsers = [
+ { id: 1, name: 'Agent 1', email: 'agent1@example.com', capacity: 15 },
+ { id: 2, name: 'Agent 2', email: 'agent2@example.com', capacity: 20 },
+ ];
+
+ axios.get.mockResolvedValue({ data: userData });
+ camelcaseKeys.mockReturnValue(camelCasedUsers);
+
+ const result = await actions.getUsers({ commit }, policyId);
+
+ expect(camelcaseKeys).toHaveBeenCalledWith(userData);
+ expect(commit.mock.calls).toEqual([
+ [types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, { isFetching: true }],
+ [
+ types.SET_AGENT_CAPACITY_POLICIES_USERS,
+ { policyId, users: camelCasedUsers },
+ ],
+ [
+ types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG,
+ { isFetching: false },
+ ],
+ ]);
+ expect(result).toEqual(userData);
+ });
+
+ it('sends correct actions if API fails', async () => {
+ axios.get.mockRejectedValue(new Error('API Error'));
+
+ await expect(actions.getUsers({ commit }, 1)).rejects.toThrow(Error);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, { isFetching: true }],
+ [
+ types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG,
+ { isFetching: false },
+ ],
+ ]);
+ });
+ });
+
+ 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();
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/fixtures.js b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/fixtures.js
new file mode 100644
index 000000000..c79597919
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/fixtures.js
@@ -0,0 +1,199 @@
+export default [
+ {
+ id: 1,
+ name: 'Standard Capacity Policy',
+ description: 'Default capacity policy for agents',
+ default_capacity: 10,
+ enabled: true,
+ account_id: 1,
+ assigned_agent_count: 3,
+ 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,
+ name: 'High Capacity Policy',
+ description: 'High capacity policy for senior agents',
+ default_capacity: 20,
+ enabled: true,
+ account_id: 1,
+ assigned_agent_count: 5,
+ created_at: '2024-01-01T11:00:00.000Z',
+ updated_at: '2024-01-01T11:00:00.000Z',
+ 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,
+ name: 'Disabled Policy',
+ description: 'Disabled capacity policy',
+ default_capacity: 5,
+ enabled: false,
+ account_id: 1,
+ assigned_agent_count: 0,
+ created_at: '2024-01-01T12:00:00.000Z',
+ updated_at: '2024-01-01T12:00:00.000Z',
+ users: [],
+ inbox_capacity_limits: [],
+ },
+];
+
+export const camelCaseFixtures = [
+ {
+ id: 1,
+ name: 'Standard Capacity Policy',
+ description: 'Default capacity policy for agents',
+ defaultCapacity: 10,
+ enabled: true,
+ accountId: 1,
+ assignedAgentCount: 3,
+ 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,
+ name: 'High Capacity Policy',
+ description: 'High capacity policy for senior agents',
+ defaultCapacity: 20,
+ enabled: true,
+ accountId: 1,
+ assignedAgentCount: 5,
+ createdAt: '2024-01-01T11:00:00.000Z',
+ updatedAt: '2024-01-01T11:00:00.000Z',
+ 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,
+ name: 'Disabled Policy',
+ description: 'Disabled capacity policy',
+ defaultCapacity: 5,
+ enabled: false,
+ accountId: 1,
+ assignedAgentCount: 0,
+ 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,
+ },
+];
diff --git a/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/getters.spec.js b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/getters.spec.js
new file mode 100644
index 000000000..2acd00ad4
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/getters.spec.js
@@ -0,0 +1,51 @@
+import { getters } from '../../agentCapacityPolicies';
+import agentCapacityPoliciesList from './fixtures';
+
+describe('#getters', () => {
+ it('getAgentCapacityPolicies', () => {
+ const state = { records: agentCapacityPoliciesList };
+ expect(getters.getAgentCapacityPolicies(state)).toEqual(
+ agentCapacityPoliciesList
+ );
+ });
+
+ it('getUIFlags', () => {
+ const state = {
+ uiFlags: {
+ isFetching: true,
+ isFetchingItem: false,
+ isCreating: false,
+ isUpdating: false,
+ isDeleting: false,
+ },
+ };
+ expect(getters.getUIFlags(state)).toEqual({
+ isFetching: true,
+ isFetchingItem: false,
+ isCreating: false,
+ isUpdating: false,
+ isDeleting: false,
+ });
+ });
+
+ it('getUsersUIFlags', () => {
+ const state = {
+ usersUiFlags: {
+ isFetching: false,
+ isDeleting: false,
+ },
+ };
+ expect(getters.getUsersUIFlags(state)).toEqual({
+ isFetching: false,
+ isDeleting: false,
+ });
+ });
+
+ it('getAgentCapacityPolicyById', () => {
+ const state = { records: agentCapacityPoliciesList };
+ expect(getters.getAgentCapacityPolicyById(state)(1)).toEqual(
+ agentCapacityPoliciesList[0]
+ );
+ expect(getters.getAgentCapacityPolicyById(state)(4)).toEqual({});
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/mutations.spec.js
new file mode 100644
index 000000000..f6cfba80d
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/agentCapacityPolicies/mutations.spec.js
@@ -0,0 +1,619 @@
+import { mutations } from '../../agentCapacityPolicies';
+import types from '../../../mutation-types';
+import agentCapacityPoliciesList, { mockUsers } from './fixtures';
+
+describe('#mutations', () => {
+ describe('#SET_AGENT_CAPACITY_POLICIES_UI_FLAG', () => {
+ it('sets single ui flag', () => {
+ const state = {
+ uiFlags: {
+ isFetching: false,
+ isCreating: false,
+ },
+ };
+
+ mutations[types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG](state, {
+ isFetching: true,
+ });
+
+ expect(state.uiFlags).toEqual({
+ isFetching: true,
+ isCreating: false,
+ });
+ });
+
+ it('sets multiple ui flags', () => {
+ const state = {
+ uiFlags: {
+ isFetching: false,
+ isCreating: false,
+ isUpdating: false,
+ },
+ };
+
+ mutations[types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG](state, {
+ isFetching: true,
+ isCreating: true,
+ });
+
+ expect(state.uiFlags).toEqual({
+ isFetching: true,
+ isCreating: true,
+ isUpdating: false,
+ });
+ });
+ });
+
+ describe('#SET_AGENT_CAPACITY_POLICIES', () => {
+ it('sets agent capacity policies records', () => {
+ const state = { records: [] };
+
+ mutations[types.SET_AGENT_CAPACITY_POLICIES](
+ state,
+ agentCapacityPoliciesList
+ );
+
+ expect(state.records).toEqual(agentCapacityPoliciesList);
+ });
+
+ it('replaces existing records', () => {
+ const state = { records: [{ id: 999, name: 'Old Policy' }] };
+
+ mutations[types.SET_AGENT_CAPACITY_POLICIES](
+ state,
+ agentCapacityPoliciesList
+ );
+
+ expect(state.records).toEqual(agentCapacityPoliciesList);
+ });
+ });
+
+ describe('#SET_AGENT_CAPACITY_POLICY', () => {
+ it('sets single agent capacity policy record', () => {
+ const state = { records: [] };
+
+ mutations[types.SET_AGENT_CAPACITY_POLICY](
+ state,
+ agentCapacityPoliciesList[0]
+ );
+
+ expect(state.records).toEqual([agentCapacityPoliciesList[0]]);
+ });
+
+ it('replaces existing record', () => {
+ const state = { records: [{ id: 1, name: 'Old Policy' }] };
+
+ mutations[types.SET_AGENT_CAPACITY_POLICY](
+ state,
+ agentCapacityPoliciesList[0]
+ );
+
+ expect(state.records).toEqual([agentCapacityPoliciesList[0]]);
+ });
+ });
+
+ describe('#ADD_AGENT_CAPACITY_POLICY', () => {
+ it('adds new policy to empty records', () => {
+ const state = { records: [] };
+
+ mutations[types.ADD_AGENT_CAPACITY_POLICY](
+ state,
+ agentCapacityPoliciesList[0]
+ );
+
+ expect(state.records).toEqual([agentCapacityPoliciesList[0]]);
+ });
+
+ it('adds new policy to existing records', () => {
+ const state = { records: [agentCapacityPoliciesList[0]] };
+
+ mutations[types.ADD_AGENT_CAPACITY_POLICY](
+ state,
+ agentCapacityPoliciesList[1]
+ );
+
+ expect(state.records).toEqual([
+ agentCapacityPoliciesList[0],
+ agentCapacityPoliciesList[1],
+ ]);
+ });
+ });
+
+ describe('#EDIT_AGENT_CAPACITY_POLICY', () => {
+ it('updates existing policy by id', () => {
+ const state = {
+ records: [
+ { ...agentCapacityPoliciesList[0] },
+ { ...agentCapacityPoliciesList[1] },
+ ],
+ };
+
+ const updatedPolicy = {
+ ...agentCapacityPoliciesList[0],
+ name: 'Updated Policy Name',
+ description: 'Updated Description',
+ };
+
+ mutations[types.EDIT_AGENT_CAPACITY_POLICY](state, updatedPolicy);
+
+ expect(state.records[0]).toEqual(updatedPolicy);
+ expect(state.records[1]).toEqual(agentCapacityPoliciesList[1]);
+ });
+
+ it('updates policy with camelCase properties', () => {
+ const camelCasePolicy = {
+ id: 1,
+ name: 'Camel Case Policy',
+ defaultCapacity: 15,
+ enabled: true,
+ };
+
+ const state = {
+ records: [camelCasePolicy],
+ };
+
+ const updatedPolicy = {
+ ...camelCasePolicy,
+ name: 'Updated Camel Case',
+ defaultCapacity: 25,
+ };
+
+ mutations[types.EDIT_AGENT_CAPACITY_POLICY](state, updatedPolicy);
+
+ expect(state.records[0]).toEqual(updatedPolicy);
+ });
+
+ it('does nothing if policy id not found', () => {
+ const state = {
+ records: [agentCapacityPoliciesList[0]],
+ };
+
+ const nonExistentPolicy = {
+ id: 999,
+ name: 'Non-existent',
+ };
+
+ const originalRecords = [...state.records];
+ mutations[types.EDIT_AGENT_CAPACITY_POLICY](state, nonExistentPolicy);
+
+ expect(state.records).toEqual(originalRecords);
+ });
+ });
+
+ describe('#DELETE_AGENT_CAPACITY_POLICY', () => {
+ it('deletes policy by id', () => {
+ const state = {
+ records: [agentCapacityPoliciesList[0], agentCapacityPoliciesList[1]],
+ };
+
+ mutations[types.DELETE_AGENT_CAPACITY_POLICY](state, 1);
+
+ expect(state.records).toEqual([agentCapacityPoliciesList[1]]);
+ });
+
+ it('does nothing if id not found', () => {
+ const state = {
+ records: [agentCapacityPoliciesList[0]],
+ };
+
+ mutations[types.DELETE_AGENT_CAPACITY_POLICY](state, 999);
+
+ expect(state.records).toEqual([agentCapacityPoliciesList[0]]);
+ });
+
+ it('handles empty records', () => {
+ const state = { records: [] };
+
+ mutations[types.DELETE_AGENT_CAPACITY_POLICY](state, 1);
+
+ expect(state.records).toEqual([]);
+ });
+ });
+
+ describe('#SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG', () => {
+ it('sets users ui flags', () => {
+ const state = {
+ usersUiFlags: {
+ isFetching: false,
+ },
+ };
+
+ mutations[types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG](state, {
+ isFetching: true,
+ });
+
+ expect(state.usersUiFlags).toEqual({
+ isFetching: true,
+ });
+ });
+
+ it('merges with existing flags', () => {
+ const state = {
+ usersUiFlags: {
+ isFetching: false,
+ isDeleting: true,
+ },
+ };
+
+ mutations[types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG](state, {
+ isFetching: true,
+ });
+
+ expect(state.usersUiFlags).toEqual({
+ isFetching: true,
+ isDeleting: true,
+ });
+ });
+ });
+
+ describe('#SET_AGENT_CAPACITY_POLICIES_USERS', () => {
+ it('sets users for existing policy', () => {
+ const testUsers = [
+ { id: 1, name: 'Agent 1', email: 'agent1@example.com', capacity: 15 },
+ { id: 2, name: 'Agent 2', email: 'agent2@example.com', capacity: 20 },
+ ];
+
+ const state = {
+ records: [
+ { id: 1, name: 'Policy 1', users: [] },
+ { id: 2, name: 'Policy 2', users: [] },
+ ],
+ };
+
+ mutations[types.SET_AGENT_CAPACITY_POLICIES_USERS](state, {
+ policyId: 1,
+ users: testUsers,
+ });
+
+ expect(state.records[0].users).toEqual(testUsers);
+ expect(state.records[1].users).toEqual([]);
+ });
+
+ it('replaces existing users', () => {
+ const oldUsers = [{ id: 99, name: 'Old Agent', capacity: 5 }];
+ const newUsers = [{ id: 1, name: 'New Agent', capacity: 15 }];
+
+ const state = {
+ records: [{ id: 1, name: 'Policy 1', users: oldUsers }],
+ };
+
+ mutations[types.SET_AGENT_CAPACITY_POLICIES_USERS](state, {
+ policyId: 1,
+ users: newUsers,
+ });
+
+ expect(state.records[0].users).toEqual(newUsers);
+ });
+
+ it('does nothing if policy not found', () => {
+ const state = {
+ records: [{ id: 1, name: 'Policy 1', users: [] }],
+ };
+
+ const originalState = JSON.parse(JSON.stringify(state));
+
+ mutations[types.SET_AGENT_CAPACITY_POLICIES_USERS](state, {
+ policyId: 999,
+ users: [{ id: 1, name: 'Test' }],
+ });
+
+ 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);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js
new file mode 100644
index 000000000..1398f5959
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js
@@ -0,0 +1,326 @@
+import axios from 'axios';
+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', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('#get', () => {
+ it('sends correct actions if API is success', async () => {
+ axios.get.mockResolvedValue({ data: assignmentPoliciesList });
+ camelcaseKeys.mockReturnValue(camelCaseFixtures);
+
+ await actions.get({ commit });
+
+ expect(camelcaseKeys).toHaveBeenCalledWith(assignmentPoliciesList);
+ expect(commit.mock.calls).toEqual([
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: true }],
+ [types.SET_ASSIGNMENT_POLICIES, camelCaseFixtures],
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: false }],
+ ]);
+ });
+
+ it('sends correct actions if API is error', async () => {
+ axios.get.mockRejectedValue({ message: 'Incorrect header' });
+
+ await actions.get({ commit });
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: true }],
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: false }],
+ ]);
+ });
+ });
+
+ describe('#show', () => {
+ it('sends correct actions if API is success', async () => {
+ const policyData = assignmentPoliciesList[0];
+ const camelCasedPolicy = camelCaseFixtures[0];
+
+ axios.get.mockResolvedValue({ data: policyData });
+ camelcaseKeys.mockReturnValue(camelCasedPolicy);
+
+ await actions.show({ commit }, 1);
+
+ expect(camelcaseKeys).toHaveBeenCalledWith(policyData);
+ expect(commit.mock.calls).toEqual([
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: true }],
+ [types.SET_ASSIGNMENT_POLICY, camelCasedPolicy],
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: false }],
+ ]);
+ });
+
+ it('sends correct actions if API is error', async () => {
+ axios.get.mockRejectedValue({ message: 'Not found' });
+
+ await actions.show({ commit }, 1);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: true }],
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: false }],
+ ]);
+ });
+ });
+
+ describe('#create', () => {
+ 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 }],
+ [types.ADD_ASSIGNMENT_POLICY, camelCasedData],
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: false }],
+ ]);
+ expect(result).toEqual(newPolicy);
+ });
+
+ it('sends correct actions if API is error', async () => {
+ axios.post.mockRejectedValue(new Error('Validation error'));
+
+ await expect(actions.create({ commit }, {})).rejects.toThrow(Error);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: true }],
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: false }],
+ ]);
+ });
+ });
+
+ describe('#update', () => {
+ it('sends correct actions if API is success', async () => {
+ const updateParams = { id: 1, name: 'Updated Policy' };
+ const responseData = {
+ ...assignmentPoliciesList[0],
+ name: 'Updated Policy',
+ };
+ const camelCasedData = {
+ ...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 }],
+ [types.EDIT_ASSIGNMENT_POLICY, camelCasedData],
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: false }],
+ ]);
+ expect(result).toEqual(responseData);
+ });
+
+ it('sends correct actions if API is error', async () => {
+ axios.patch.mockRejectedValue(new Error('Validation error'));
+
+ await expect(
+ actions.update({ commit }, { id: 1, name: 'Test' })
+ ).rejects.toThrow(Error);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: true }],
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: false }],
+ ]);
+ });
+ });
+
+ describe('#delete', () => {
+ it('sends correct actions if API is success', async () => {
+ const policyId = 1;
+ axios.delete.mockResolvedValue({});
+
+ await actions.delete({ commit }, policyId);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: true }],
+ [types.DELETE_ASSIGNMENT_POLICY, policyId],
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: false }],
+ ]);
+ });
+
+ it('sends correct actions if API is error', async () => {
+ axios.delete.mockRejectedValue(new Error('Not found'));
+
+ await expect(actions.delete({ commit }, 1)).rejects.toThrow(Error);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: true }],
+ [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: false }],
+ ]);
+ });
+ });
+
+ describe('#getInboxes', () => {
+ it('sends correct actions if API is success', async () => {
+ const policyId = 1;
+ const inboxData = {
+ inboxes: [
+ { id: 1, name: 'Support' },
+ { id: 2, name: 'Sales' },
+ ],
+ };
+ const camelCasedInboxes = [
+ { id: 1, name: 'Support' },
+ { id: 2, name: 'Sales' },
+ ];
+
+ axios.get.mockResolvedValue({ data: inboxData });
+ camelcaseKeys.mockReturnValue(camelCasedInboxes);
+
+ await actions.getInboxes({ commit }, policyId);
+
+ expect(camelcaseKeys).toHaveBeenCalledWith(inboxData.inboxes);
+ expect(commit.mock.calls).toEqual([
+ [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: true }],
+ [
+ types.SET_ASSIGNMENT_POLICIES_INBOXES,
+ { policyId, inboxes: camelCasedInboxes },
+ ],
+ [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: false }],
+ ]);
+ });
+
+ it('sends correct actions if API fails', async () => {
+ axios.get.mockRejectedValue(new Error('API Error'));
+
+ await expect(actions.getInboxes({ commit }, 1)).rejects.toThrow(Error);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: true }],
+ [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: false }],
+ ]);
+ });
+ });
+
+ 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 }],
+ ]);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/fixtures.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/fixtures.js
new file mode 100644
index 000000000..1b5ed25af
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/fixtures.js
@@ -0,0 +1,57 @@
+export default [
+ {
+ id: 1,
+ name: 'Round Robin Policy',
+ description: 'Distributes conversations evenly among agents',
+ assignment_order: 'round_robin',
+ conversation_priority: 'earliest_created',
+ fair_distribution_limit: 100,
+ fair_distribution_window: 3600,
+ enabled: true,
+ assigned_inbox_count: 3,
+ created_at: 1704110400,
+ updated_at: 1704110400,
+ },
+ {
+ id: 2,
+ name: 'Balanced Policy',
+ description: 'Assigns conversations based on agent capacity',
+ assignment_order: 'balanced',
+ conversation_priority: 'longest_waiting',
+ fair_distribution_limit: 50,
+ fair_distribution_window: 1800,
+ enabled: false,
+ assigned_inbox_count: 1,
+ created_at: 1704114000,
+ updated_at: 1704114000,
+ },
+];
+
+export const camelCaseFixtures = [
+ {
+ id: 1,
+ name: 'Round Robin Policy',
+ description: 'Distributes conversations evenly among agents',
+ assignmentOrder: 'round_robin',
+ conversationPriority: 'earliest_created',
+ fairDistributionLimit: 100,
+ fairDistributionWindow: 3600,
+ enabled: true,
+ assignedInboxCount: 3,
+ createdAt: 1704110400,
+ updatedAt: 1704110400,
+ },
+ {
+ id: 2,
+ name: 'Balanced Policy',
+ description: 'Assigns conversations based on agent capacity',
+ assignmentOrder: 'balanced',
+ conversationPriority: 'longest_waiting',
+ fairDistributionLimit: 50,
+ fairDistributionWindow: 1800,
+ enabled: false,
+ assignedInboxCount: 1,
+ createdAt: 1704114000,
+ updatedAt: 1704114000,
+ },
+];
diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js
new file mode 100644
index 000000000..4fd1e7ad7
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js
@@ -0,0 +1,51 @@
+import { getters } from '../../assignmentPolicies';
+import assignmentPoliciesList from './fixtures';
+
+describe('#getters', () => {
+ it('getAssignmentPolicies', () => {
+ const state = { records: assignmentPoliciesList };
+ expect(getters.getAssignmentPolicies(state)).toEqual(
+ assignmentPoliciesList
+ );
+ });
+
+ it('getUIFlags', () => {
+ const state = {
+ uiFlags: {
+ isFetching: true,
+ isFetchingItem: false,
+ isCreating: false,
+ isUpdating: false,
+ isDeleting: false,
+ },
+ };
+ expect(getters.getUIFlags(state)).toEqual({
+ isFetching: true,
+ isFetchingItem: false,
+ isCreating: false,
+ isUpdating: false,
+ isDeleting: false,
+ });
+ });
+
+ it('getInboxUiFlags', () => {
+ const state = {
+ inboxUiFlags: {
+ isFetching: false,
+ isDeleting: false,
+ },
+ };
+ expect(getters.getInboxUiFlags(state)).toEqual({
+ isFetching: false,
+ isDeleting: false,
+ });
+ });
+
+ it('getAssignmentPolicyById', () => {
+ const state = { records: assignmentPoliciesList };
+ expect(getters.getAssignmentPolicyById(state)(1)).toEqual(
+ assignmentPoliciesList[0]
+ );
+ expect(getters.getAssignmentPolicyById(state)(3)).toEqual({});
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js
new file mode 100644
index 000000000..b3c029c57
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js
@@ -0,0 +1,385 @@
+import { mutations } from '../../assignmentPolicies';
+import types from '../../../mutation-types';
+import assignmentPoliciesList from './fixtures';
+
+describe('#mutations', () => {
+ describe('#SET_ASSIGNMENT_POLICIES_UI_FLAG', () => {
+ it('sets single ui flag', () => {
+ const state = {
+ uiFlags: {
+ isFetching: false,
+ isCreating: false,
+ },
+ };
+
+ mutations[types.SET_ASSIGNMENT_POLICIES_UI_FLAG](state, {
+ isFetching: true,
+ });
+
+ expect(state.uiFlags).toEqual({
+ isFetching: true,
+ isCreating: false,
+ });
+ });
+
+ it('sets multiple ui flags', () => {
+ const state = {
+ uiFlags: {
+ isFetching: false,
+ isCreating: false,
+ isUpdating: false,
+ },
+ };
+
+ mutations[types.SET_ASSIGNMENT_POLICIES_UI_FLAG](state, {
+ isFetching: true,
+ isCreating: true,
+ });
+
+ expect(state.uiFlags).toEqual({
+ isFetching: true,
+ isCreating: true,
+ isUpdating: false,
+ });
+ });
+ });
+
+ describe('#SET_ASSIGNMENT_POLICIES', () => {
+ it('sets assignment policies records', () => {
+ const state = { records: [] };
+
+ mutations[types.SET_ASSIGNMENT_POLICIES](state, assignmentPoliciesList);
+
+ expect(state.records).toEqual(assignmentPoliciesList);
+ });
+
+ it('replaces existing records', () => {
+ const state = { records: [{ id: 999, name: 'Old Policy' }] };
+
+ mutations[types.SET_ASSIGNMENT_POLICIES](state, assignmentPoliciesList);
+
+ expect(state.records).toEqual(assignmentPoliciesList);
+ });
+ });
+
+ 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: [] };
+
+ mutations[types.ADD_ASSIGNMENT_POLICY](state, assignmentPoliciesList[0]);
+
+ expect(state.records).toEqual([assignmentPoliciesList[0]]);
+ });
+
+ it('adds new policy to existing records', () => {
+ const state = { records: [assignmentPoliciesList[0]] };
+
+ mutations[types.ADD_ASSIGNMENT_POLICY](state, assignmentPoliciesList[1]);
+
+ expect(state.records).toEqual([
+ assignmentPoliciesList[0],
+ assignmentPoliciesList[1],
+ ]);
+ });
+ });
+
+ describe('#EDIT_ASSIGNMENT_POLICY', () => {
+ it('updates existing policy by id', () => {
+ const state = {
+ records: [
+ { ...assignmentPoliciesList[0] },
+ { ...assignmentPoliciesList[1] },
+ ],
+ };
+
+ const updatedPolicy = {
+ ...assignmentPoliciesList[0],
+ name: 'Updated Policy Name',
+ description: 'Updated Description',
+ };
+
+ mutations[types.EDIT_ASSIGNMENT_POLICY](state, updatedPolicy);
+
+ expect(state.records[0]).toEqual(updatedPolicy);
+ expect(state.records[1]).toEqual(assignmentPoliciesList[1]);
+ });
+
+ it('updates policy with camelCase properties', () => {
+ const camelCasePolicy = {
+ id: 1,
+ name: 'Camel Case Policy',
+ assignmentOrder: 'round_robin',
+ conversationPriority: 'earliest_created',
+ };
+
+ const state = {
+ records: [camelCasePolicy],
+ };
+
+ const updatedPolicy = {
+ ...camelCasePolicy,
+ name: 'Updated Camel Case',
+ assignmentOrder: 'balanced',
+ };
+
+ mutations[types.EDIT_ASSIGNMENT_POLICY](state, updatedPolicy);
+
+ expect(state.records[0]).toEqual(updatedPolicy);
+ });
+
+ it('does nothing if policy id not found', () => {
+ const state = {
+ records: [assignmentPoliciesList[0]],
+ };
+
+ const nonExistentPolicy = {
+ id: 999,
+ name: 'Non-existent',
+ };
+
+ const originalRecords = [...state.records];
+ mutations[types.EDIT_ASSIGNMENT_POLICY](state, nonExistentPolicy);
+
+ expect(state.records).toEqual(originalRecords);
+ });
+ });
+
+ describe('#DELETE_ASSIGNMENT_POLICY', () => {
+ it('deletes policy by id', () => {
+ const state = {
+ records: [assignmentPoliciesList[0], assignmentPoliciesList[1]],
+ };
+
+ mutations[types.DELETE_ASSIGNMENT_POLICY](state, 1);
+
+ expect(state.records).toEqual([assignmentPoliciesList[1]]);
+ });
+
+ it('does nothing if id not found', () => {
+ const state = {
+ records: [assignmentPoliciesList[0]],
+ };
+
+ mutations[types.DELETE_ASSIGNMENT_POLICY](state, 999);
+
+ expect(state.records).toEqual([assignmentPoliciesList[0]]);
+ });
+
+ it('handles empty records', () => {
+ const state = { records: [] };
+
+ mutations[types.DELETE_ASSIGNMENT_POLICY](state, 1);
+
+ expect(state.records).toEqual([]);
+ });
+ });
+
+ describe('#SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG', () => {
+ it('sets inbox ui flags', () => {
+ const state = {
+ inboxUiFlags: {
+ isFetching: false,
+ },
+ };
+
+ mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG](state, {
+ isFetching: true,
+ });
+
+ expect(state.inboxUiFlags).toEqual({
+ isFetching: true,
+ });
+ });
+
+ it('merges with existing flags', () => {
+ const state = {
+ inboxUiFlags: {
+ isFetching: false,
+ isLoading: true,
+ },
+ };
+
+ mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG](state, {
+ isFetching: true,
+ });
+
+ expect(state.inboxUiFlags).toEqual({
+ isFetching: true,
+ isLoading: true,
+ });
+ });
+ });
+
+ describe('#SET_ASSIGNMENT_POLICIES_INBOXES', () => {
+ it('sets inboxes for existing policy', () => {
+ const mockInboxes = [
+ { id: 1, name: 'Support Inbox' },
+ { id: 2, name: 'Sales Inbox' },
+ ];
+
+ const state = {
+ records: [
+ { id: 1, name: 'Policy 1', inboxes: [] },
+ { id: 2, name: 'Policy 2', inboxes: [] },
+ ],
+ };
+
+ mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES](state, {
+ policyId: 1,
+ inboxes: mockInboxes,
+ });
+
+ expect(state.records[0].inboxes).toEqual(mockInboxes);
+ expect(state.records[1].inboxes).toEqual([]);
+ });
+
+ it('replaces existing inboxes', () => {
+ const oldInboxes = [{ id: 99, name: 'Old Inbox' }];
+ const newInboxes = [{ id: 1, name: 'New Inbox' }];
+
+ const state = {
+ records: [{ id: 1, name: 'Policy 1', inboxes: oldInboxes }],
+ };
+
+ mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES](state, {
+ policyId: 1,
+ inboxes: newInboxes,
+ });
+
+ expect(state.records[0].inboxes).toEqual(newInboxes);
+ });
+
+ it('does nothing if policy not found', () => {
+ const state = {
+ records: [{ id: 1, name: 'Policy 1', inboxes: [] }],
+ };
+
+ const originalState = JSON.parse(JSON.stringify(state));
+
+ mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES](state, {
+ policyId: 999,
+ inboxes: [{ id: 1, name: 'Test' }],
+ });
+
+ 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,
+ });
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index a63fec2d1..4f361e140 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -348,4 +348,34 @@ export default {
SET_TEAM_CONVERSATION_METRIC: 'SET_TEAM_CONVERSATION_METRIC',
TOGGLE_TEAM_CONVERSATION_METRIC_LOADING:
'TOGGLE_TEAM_CONVERSATION_METRIC_LOADING',
+
+ // 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',
+
+ // Agent Capacity Policies
+ SET_AGENT_CAPACITY_POLICIES_UI_FLAG: 'SET_AGENT_CAPACITY_POLICIES_UI_FLAG',
+ SET_AGENT_CAPACITY_POLICIES: 'SET_AGENT_CAPACITY_POLICIES',
+ SET_AGENT_CAPACITY_POLICY: 'SET_AGENT_CAPACITY_POLICY',
+ ADD_AGENT_CAPACITY_POLICY: 'ADD_AGENT_CAPACITY_POLICY',
+ EDIT_AGENT_CAPACITY_POLICY: 'EDIT_AGENT_CAPACITY_POLICY',
+ DELETE_AGENT_CAPACITY_POLICY: 'DELETE_AGENT_CAPACITY_POLICY',
+ 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',
};
diff --git a/app/javascript/histoire.setup.ts b/app/javascript/histoire.setup.ts
index 7642da78d..1c80a9f85 100644
--- a/app/javascript/histoire.setup.ts
+++ b/app/javascript/histoire.setup.ts
@@ -1,6 +1,7 @@
import './design-system/histoire.scss';
import { defineSetupVue3 } from '@histoire/plugin-vue';
-import i18nMessages from 'dashboard/i18n';
+import dashboardI18n from 'dashboard/i18n';
+import widgetI18n from 'widget/i18n';
import { createI18n } from 'vue-i18n';
import { vResizeObserver } from '@vueuse/components';
import store from 'dashboard/store';
@@ -9,10 +10,30 @@ import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
import { directive as onClickaway } from 'vue3-click-away';
+function mergeMessages(...sources) {
+ return sources.reduce((acc, src) => {
+ Object.keys(src).forEach(key => {
+ if (
+ acc[key] &&
+ typeof acc[key] === 'object' &&
+ typeof src[key] === 'object'
+ ) {
+ acc[key] = mergeMessages(acc[key], src[key]);
+ } else {
+ acc[key] = src[key];
+ }
+ });
+ return acc;
+ }, {});
+}
+
const i18n = createI18n({
legacy: false, // https://github.com/intlify/vue-i18n/issues/1902
locale: 'en',
- messages: i18nMessages,
+ messages: mergeMessages(
+ structuredClone(dashboardI18n),
+ structuredClone(widgetI18n)
+ ),
});
export const setupVue3 = defineSetupVue3(({ app }) => {
diff --git a/app/javascript/sdk/IFrameHelper.js b/app/javascript/sdk/IFrameHelper.js
index 25f90912d..3ab1ba1f5 100644
--- a/app/javascript/sdk/IFrameHelper.js
+++ b/app/javascript/sdk/IFrameHelper.js
@@ -50,35 +50,11 @@ const updateCampaignReadStatus = baseDomain => {
});
};
-const sanitizeURL = url => {
- if (url === '') return '';
-
- try {
- // any invalid url will not be accepted
- // example - JaVaScRiP%0at:alert(document.domain)"
- // this has an obfuscated javascript protocol
- const parsedURL = new URL(url);
-
- // filter out dangerous protocols like `javascript`, `data`, `vbscript`
- if (!['https', 'http'].includes(parsedURL.protocol)) {
- throw new Error('Invalid Protocol');
- }
- } catch (e) {
- // eslint-disable-next-line no-console
- console.error('Invalid URL', e);
- }
-
- return 'about:blank'; // blank page URL
-};
-
export const IFrameHelper = {
getUrl({ baseUrl, websiteToken }) {
- baseUrl = sanitizeURL(baseUrl);
return `${baseUrl}/widget?website_token=${websiteToken}`;
},
createFrame: ({ baseUrl, websiteToken }) => {
- baseUrl = sanitizeURL(baseUrl);
-
if (IFrameHelper.getAppFrame()) {
return;
}
@@ -126,12 +102,10 @@ export const IFrameHelper = {
window.onmessage = e => {
if (
typeof e.data !== 'string' ||
- e.data.indexOf('chatwoot-widget:') !== 0 ||
- e.origin !== window.location.origin
+ e.data.indexOf('chatwoot-widget:') !== 0
) {
return;
}
-
const message = JSON.parse(e.data.replace('chatwoot-widget:', ''));
if (typeof IFrameHelper.events[message.event] === 'function') {
IFrameHelper.events[message.event](message);
@@ -166,9 +140,7 @@ export const IFrameHelper = {
},
setupAudioListeners: () => {
- let { baseUrl = '' } = window.$chatwoot;
- baseUrl = sanitizeURL(baseUrl);
-
+ const { baseUrl = '' } = window.$chatwoot;
getAlertAudio(baseUrl, { type: 'widget', alertTone: 'ding' }).then(() =>
initOnEvents.forEach(event => {
document.removeEventListener(
@@ -262,7 +234,6 @@ export const IFrameHelper = {
},
popoutChatWindow: ({ baseUrl, websiteToken, locale }) => {
- baseUrl = sanitizeURL(baseUrl);
const cwCookie = Cookies.get('cw_conversation');
window.$chatwoot.toggle('close');
popoutChatWindow(baseUrl, websiteToken, locale, cwCookie);
diff --git a/app/javascript/shared/components/Branding.vue b/app/javascript/shared/components/Branding.vue
index 9ca976d80..2f1a8d5d0 100644
--- a/app/javascript/shared/components/Branding.vue
+++ b/app/javascript/shared/components/Branding.vue
@@ -33,13 +33,15 @@ export default {
brandRedirectURL() {
try {
const referrerHost = this.$store.getters['appConfig/getReferrerHost'];
- const baseURL = `${this.globalConfig.widgetBrandURL}?utm_source=${
- referrerHost ? 'widget_branding' : 'survey_branding'
- }`;
+ const url = new URL(this.globalConfig.widgetBrandURL);
if (referrerHost) {
- return `${baseURL}&utm_referrer=${referrerHost}`;
+ url.searchParams.set('utm_source', referrerHost);
+ url.searchParams.set('utm_medium', 'widget');
+ } else {
+ url.searchParams.set('utm_medium', 'survey');
}
- return baseURL;
+ url.searchParams.set('utm_campaign', 'branding');
+ return url.toString();
} catch (e) {
// Suppressing the error as getter is not defined in some cases
}
diff --git a/app/javascript/shared/constants/messages.js b/app/javascript/shared/constants/messages.js
index 7b7b4f331..47c1e8651 100644
--- a/app/javascript/shared/constants/messages.js
+++ b/app/javascript/shared/constants/messages.js
@@ -36,7 +36,6 @@ export const CONVERSATION_PRIORITY_ORDER = {
// Size in mega bytes
export const MAXIMUM_FILE_UPLOAD_SIZE = 40;
-export const MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL = 5;
export const ALLOWED_FILE_TYPES =
'image/*,' +
@@ -50,18 +49,6 @@ export const ALLOWED_FILE_TYPES =
'application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,' +
'application/vnd.openxmlformats-officedocument.wordprocessingml.document,';
-export const ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP =
- 'image/png, image/jpeg,' +
- 'audio/mpeg, audio/opus, audio/ogg, audio/amr,' +
- 'video/mp4,' +
- 'application/pdf,';
-// https://developers.line.biz/en/reference/messaging-api/#image-message, https://developers.line.biz/en/reference/messaging-api/#video-message
-export const ALLOWED_FILE_TYPES_FOR_LINE = 'image/png, image/jpeg,video/mp4';
-
-// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#requirements
-export const ALLOWED_FILE_TYPES_FOR_INSTAGRAM =
- 'image/png, image/jpeg, video/mp4, video/mov, video/webm';
-
export const CSAT_RATINGS = [
{
key: 'disappointed',
@@ -175,3 +162,9 @@ export const ATTACHMENT_ICONS = {
location: 'location',
fallback: 'link',
};
+
+export const TWILIO_CONTENT_TEMPLATE_TYPES = {
+ TEXT: 'text',
+ MEDIA: 'media',
+ QUICK_REPLY: 'quick_reply',
+};
diff --git a/app/javascript/shared/helpers/clipboard.js b/app/javascript/shared/helpers/clipboard.js
index dbadd4037..3b66c39ac 100644
--- a/app/javascript/shared/helpers/clipboard.js
+++ b/app/javascript/shared/helpers/clipboard.js
@@ -2,13 +2,37 @@
* Writes a text string to the system clipboard.
*
* @async
- * @param {string} text text to be written to the clipboard
+ * @param {string} data text to be written to the clipboard
* @throws {Error} unable to copy text to clipboard
*/
-export const copyTextToClipboard = async text => {
+export const copyTextToClipboard = async data => {
try {
+ const text =
+ typeof data === 'object' && data !== null
+ ? JSON.stringify(data, null, 2)
+ : String(data ?? '');
+
await navigator.clipboard.writeText(text);
} catch (error) {
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
}
};
+
+/**
+ * Handles OTP paste events by extracting numeric digits from clipboard data.
+ *
+ * @param {ClipboardEvent} event - The paste event from the clipboard
+ * @param {number} maxLength - Maximum number of digits to extract (default: 6)
+ * @returns {string|null} - Extracted numeric string or null if invalid
+ */
+export const handleOtpPaste = (event, maxLength = 6) => {
+ if (!event?.clipboardData) return null;
+
+ const pastedData = event.clipboardData
+ .getData('text')
+ .replace(/\D/g, '') // Remove all non-digit characters
+ .slice(0, maxLength); // Limit to maxLength digits
+
+ // Only return if we have the exact expected length
+ return pastedData.length === maxLength ? pastedData : null;
+};
diff --git a/app/javascript/shared/helpers/documentHelper.js b/app/javascript/shared/helpers/documentHelper.js
new file mode 100644
index 000000000..f050ad54d
--- /dev/null
+++ b/app/javascript/shared/helpers/documentHelper.js
@@ -0,0 +1,38 @@
+/**
+ * Document Helper - utilities for document display and formatting
+ */
+
+// Constants for document processing
+const PDF_PREFIX = 'PDF:';
+const TIMESTAMP_PATTERN = /_\d{14}(?=\.pdf$)/; // Format: _YYYYMMDDHHMMSS before .pdf extension
+
+/**
+ * Checks if a document is a PDF based on its external link
+ * @param {string} externalLink - The external link string
+ * @returns {boolean} True if the document is a PDF
+ */
+export const isPdfDocument = externalLink => {
+ if (!externalLink) return false;
+ return externalLink.startsWith(PDF_PREFIX);
+};
+
+/**
+ * Formats the display link for documents
+ * For PDF documents: removes 'PDF:' prefix and timestamp suffix
+ * For regular URLs: returns as-is
+ *
+ * @param {string} externalLink - The external link string
+ * @returns {string} Formatted display link
+ */
+export const formatDocumentLink = externalLink => {
+ if (!externalLink) return '';
+
+ if (isPdfDocument(externalLink)) {
+ // Remove 'PDF:' prefix
+ const fullName = externalLink.substring(PDF_PREFIX.length);
+ // Remove timestamp suffix if present
+ return fullName.replace(TIMESTAMP_PATTERN, '');
+ }
+
+ return externalLink;
+};
diff --git a/app/javascript/shared/helpers/specs/clipboard.spec.js b/app/javascript/shared/helpers/specs/clipboard.spec.js
new file mode 100644
index 000000000..a169e0939
--- /dev/null
+++ b/app/javascript/shared/helpers/specs/clipboard.spec.js
@@ -0,0 +1,284 @@
+import { copyTextToClipboard, handleOtpPaste } from '../clipboard';
+
+const mockWriteText = vi.fn();
+Object.assign(navigator, {
+ clipboard: {
+ writeText: mockWriteText,
+ },
+});
+
+describe('copyTextToClipboard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('with string input', () => {
+ it('copies plain text string to clipboard', async () => {
+ const text = 'Hello World';
+ await copyTextToClipboard(text);
+
+ expect(mockWriteText).toHaveBeenCalledWith('Hello World');
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+
+ it('copies empty string to clipboard', async () => {
+ const text = '';
+ await copyTextToClipboard(text);
+
+ expect(mockWriteText).toHaveBeenCalledWith('');
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('with number input', () => {
+ it('converts number to string', async () => {
+ await copyTextToClipboard(42);
+
+ expect(mockWriteText).toHaveBeenCalledWith('42');
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+
+ it('converts zero to string', async () => {
+ await copyTextToClipboard(0);
+
+ expect(mockWriteText).toHaveBeenCalledWith('0');
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('with boolean input', () => {
+ it('converts true to string', async () => {
+ await copyTextToClipboard(true);
+
+ expect(mockWriteText).toHaveBeenCalledWith('true');
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+
+ it('converts false to string', async () => {
+ await copyTextToClipboard(false);
+
+ expect(mockWriteText).toHaveBeenCalledWith('false');
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('with null/undefined input', () => {
+ it('converts null to empty string', async () => {
+ await copyTextToClipboard(null);
+
+ expect(mockWriteText).toHaveBeenCalledWith('');
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+
+ it('converts undefined to empty string', async () => {
+ await copyTextToClipboard(undefined);
+
+ expect(mockWriteText).toHaveBeenCalledWith('');
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('with object input', () => {
+ it('stringifies simple object with proper formatting', async () => {
+ const obj = { name: 'John', age: 30 };
+ await copyTextToClipboard(obj);
+
+ const expectedJson = JSON.stringify(obj, null, 2);
+ expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+
+ it('stringifies nested object with proper formatting', async () => {
+ const nestedObj = {
+ severity: {
+ user_id: 1181505,
+ user_name: 'test',
+ server_name: '[1253]test1253',
+ },
+ };
+ await copyTextToClipboard(nestedObj);
+
+ const expectedJson = JSON.stringify(nestedObj, null, 2);
+ expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+
+ it('stringifies array with proper formatting', async () => {
+ const arr = [1, 2, { name: 'test' }];
+ await copyTextToClipboard(arr);
+
+ const expectedJson = JSON.stringify(arr, null, 2);
+ expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+
+ it('stringifies empty object', async () => {
+ const obj = {};
+ await copyTextToClipboard(obj);
+
+ expect(mockWriteText).toHaveBeenCalledWith('{}');
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+
+ it('stringifies empty array', async () => {
+ const arr = [];
+ await copyTextToClipboard(arr);
+
+ expect(mockWriteText).toHaveBeenCalledWith('[]');
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('error handling', () => {
+ it('throws error when clipboard API fails', async () => {
+ const error = new Error('Clipboard access denied');
+ mockWriteText.mockRejectedValueOnce(error);
+
+ await expect(copyTextToClipboard('test')).rejects.toThrow(
+ 'Unable to copy text to clipboard: Clipboard access denied'
+ );
+ });
+
+ it('handles clipboard API not available', async () => {
+ // Temporarily remove clipboard API
+ const originalClipboard = navigator.clipboard;
+ delete navigator.clipboard;
+
+ await expect(copyTextToClipboard('test')).rejects.toThrow(
+ 'Unable to copy text to clipboard:'
+ );
+
+ // Restore clipboard API
+ navigator.clipboard = originalClipboard;
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles Date objects', async () => {
+ const date = new Date('2023-01-01T00:00:00.000Z');
+ await copyTextToClipboard(date);
+
+ const expectedJson = JSON.stringify(date, null, 2);
+ expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles functions by converting to string', async () => {
+ const func = () => 'test';
+ await copyTextToClipboard(func);
+
+ expect(mockWriteText).toHaveBeenCalledWith(func.toString());
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+ });
+ });
+});
+
+describe('handleOtpPaste', () => {
+ // Helper function to create mock clipboard event
+ const createMockPasteEvent = text => ({
+ clipboardData: {
+ getData: vi.fn().mockReturnValue(text),
+ },
+ });
+
+ describe('valid OTP paste scenarios', () => {
+ it('extracts 6-digit OTP from clean numeric string', () => {
+ const event = createMockPasteEvent('123456');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBe('123456');
+ expect(event.clipboardData.getData).toHaveBeenCalledWith('text');
+ });
+
+ it('extracts 6-digit OTP from string with spaces', () => {
+ const event = createMockPasteEvent('1 2 3 4 5 6');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBe('123456');
+ });
+
+ it('extracts 6-digit OTP from string with dashes', () => {
+ const event = createMockPasteEvent('123-456');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBe('123456');
+ });
+
+ it('handles negative numbers by extracting digits only', () => {
+ const event = createMockPasteEvent('-123456');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBe('123456');
+ });
+
+ it('handles decimal numbers by extracting digits only', () => {
+ const event = createMockPasteEvent('123.456');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBe('123456');
+ });
+
+ it('extracts 6-digit OTP from mixed alphanumeric string', () => {
+ const event = createMockPasteEvent('Your code is: 987654');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBe('987654');
+ });
+
+ it('extracts first 6 digits when more than 6 digits present', () => {
+ const event = createMockPasteEvent('12345678901234');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBe('123456');
+ });
+
+ it('handles custom maxLength parameter', () => {
+ const event = createMockPasteEvent('12345678');
+ const result = handleOtpPaste(event, 8);
+
+ expect(result).toBe('12345678');
+ });
+
+ it('extracts 4-digit OTP with custom maxLength', () => {
+ const event = createMockPasteEvent('Your PIN: 9876');
+ const result = handleOtpPaste(event, 4);
+
+ expect(result).toBe('9876');
+ });
+ });
+
+ describe('invalid OTP paste scenarios', () => {
+ it('returns null for insufficient digits', () => {
+ const event = createMockPasteEvent('12345');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null for text with no digits', () => {
+ const event = createMockPasteEvent('Hello World');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null for empty string', () => {
+ const event = createMockPasteEvent('');
+ const result = handleOtpPaste(event);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null when event is null', () => {
+ const result = handleOtpPaste(null);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null when event is undefined', () => {
+ const result = handleOtpPaste(undefined);
+
+ expect(result).toBeNull();
+ });
+ });
+});
diff --git a/app/javascript/shared/helpers/specs/documentHelper.spec.js b/app/javascript/shared/helpers/specs/documentHelper.spec.js
new file mode 100644
index 000000000..64baf7069
--- /dev/null
+++ b/app/javascript/shared/helpers/specs/documentHelper.spec.js
@@ -0,0 +1,111 @@
+import {
+ isPdfDocument,
+ formatDocumentLink,
+} from 'shared/helpers/documentHelper';
+
+describe('documentHelper', () => {
+ describe('#isPdfDocument', () => {
+ it('returns true for PDF documents', () => {
+ expect(isPdfDocument('PDF:document.pdf')).toBe(true);
+ expect(isPdfDocument('PDF:my-file_20241227123045.pdf')).toBe(true);
+ expect(isPdfDocument('PDF:report with spaces_20241227123045.pdf')).toBe(
+ true
+ );
+ });
+
+ it('returns false for regular URLs', () => {
+ expect(isPdfDocument('https://example.com')).toBe(false);
+ expect(isPdfDocument('http://docs.example.com/file.pdf')).toBe(false);
+ expect(isPdfDocument('ftp://files.example.com/document.pdf')).toBe(false);
+ });
+
+ it('returns false for empty or null values', () => {
+ expect(isPdfDocument('')).toBe(false);
+ expect(isPdfDocument(null)).toBe(false);
+ expect(isPdfDocument(undefined)).toBe(false);
+ });
+
+ it('returns false for strings that contain PDF but do not start with PDF:', () => {
+ expect(isPdfDocument('document PDF:file.pdf')).toBe(false);
+ expect(isPdfDocument('My PDF:file.pdf')).toBe(false);
+ });
+ });
+
+ describe('#formatDocumentLink', () => {
+ describe('PDF documents', () => {
+ it('removes PDF: prefix from PDF documents', () => {
+ expect(formatDocumentLink('PDF:document.pdf')).toBe('document.pdf');
+ expect(formatDocumentLink('PDF:my-file.pdf')).toBe('my-file.pdf');
+ });
+
+ it('removes timestamp suffix from PDF documents', () => {
+ expect(formatDocumentLink('PDF:document_20241227123045.pdf')).toBe(
+ 'document.pdf'
+ );
+ expect(formatDocumentLink('PDF:report_20231215094530.pdf')).toBe(
+ 'report.pdf'
+ );
+ });
+
+ it('handles PDF documents with spaces in filename', () => {
+ expect(formatDocumentLink('PDF:my document_20241227123045.pdf')).toBe(
+ 'my document.pdf'
+ );
+ expect(
+ formatDocumentLink('PDF:Annual Report 2024_20241227123045.pdf')
+ ).toBe('Annual Report 2024.pdf');
+ });
+
+ it('handles PDF documents without timestamp suffix', () => {
+ expect(formatDocumentLink('PDF:document.pdf')).toBe('document.pdf');
+ expect(formatDocumentLink('PDF:simple-file.pdf')).toBe(
+ 'simple-file.pdf'
+ );
+ });
+
+ it('handles PDF documents with partial timestamp patterns', () => {
+ expect(formatDocumentLink('PDF:document_202412.pdf')).toBe(
+ 'document_202412.pdf'
+ );
+ expect(formatDocumentLink('PDF:file_123.pdf')).toBe('file_123.pdf');
+ });
+
+ it('handles edge cases with timestamp pattern', () => {
+ expect(
+ formatDocumentLink('PDF:doc_20241227123045_final_20241227123045.pdf')
+ ).toBe('doc_20241227123045_final.pdf');
+ });
+ });
+
+ describe('Regular URLs', () => {
+ it('returns regular URLs unchanged', () => {
+ expect(formatDocumentLink('https://example.com')).toBe(
+ 'https://example.com'
+ );
+ expect(formatDocumentLink('http://docs.example.com/api')).toBe(
+ 'http://docs.example.com/api'
+ );
+ expect(formatDocumentLink('https://github.com/user/repo')).toBe(
+ 'https://github.com/user/repo'
+ );
+ });
+
+ it('handles URLs with query parameters', () => {
+ expect(formatDocumentLink('https://example.com?param=value')).toBe(
+ 'https://example.com?param=value'
+ );
+ expect(
+ formatDocumentLink(
+ 'https://api.example.com/docs?version=v1&format=json'
+ )
+ ).toBe('https://api.example.com/docs?version=v1&format=json');
+ });
+
+ it('handles URLs with fragments', () => {
+ expect(formatDocumentLink('https://example.com/docs#section1')).toBe(
+ 'https://example.com/docs#section1'
+ );
+ });
+ });
+ });
+});
diff --git a/app/javascript/shared/helpers/specs/timeHelper.spec.js b/app/javascript/shared/helpers/specs/timeHelper.spec.js
index 13d04568f..e7a4e025f 100644
--- a/app/javascript/shared/helpers/specs/timeHelper.spec.js
+++ b/app/javascript/shared/helpers/specs/timeHelper.spec.js
@@ -4,6 +4,8 @@ import {
dynamicTime,
dateFormat,
shortTimestamp,
+ getDayDifferenceFromNow,
+ hasOneDayPassed,
} from 'shared/helpers/timeHelper';
beforeEach(() => {
@@ -90,3 +92,148 @@ describe('#shortTimestamp', () => {
expect(shortTimestamp('4 years ago', true)).toEqual('4y ago');
});
});
+
+describe('#getDayDifferenceFromNow', () => {
+ it('returns 0 for timestamps from today', () => {
+ // Mock current date: May 5, 2023
+ const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // 12:00 PM
+ const todayTimestamp = Math.floor(now.getTime() / 1000); // Same day
+
+ expect(getDayDifferenceFromNow(now, todayTimestamp)).toEqual(0);
+ });
+
+ it('returns 2 for timestamps from 2 days ago', () => {
+ const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
+ const twoDaysAgoTimestamp = Math.floor(
+ new Date(Date.UTC(2023, 4, 3, 10, 0, 0)).getTime() / 1000
+ ); // May 3, 2023
+
+ expect(getDayDifferenceFromNow(now, twoDaysAgoTimestamp)).toEqual(2);
+ });
+
+ it('returns 7 for timestamps from a week ago', () => {
+ const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
+ const weekAgoTimestamp = Math.floor(
+ new Date(Date.UTC(2023, 3, 28, 8, 0, 0)).getTime() / 1000
+ ); // April 28, 2023
+
+ expect(getDayDifferenceFromNow(now, weekAgoTimestamp)).toEqual(7);
+ });
+
+ it('returns 30 for timestamps from a month ago', () => {
+ const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
+ const monthAgoTimestamp = Math.floor(
+ new Date(Date.UTC(2023, 3, 5, 12, 0, 0)).getTime() / 1000
+ ); // April 5, 2023
+
+ expect(getDayDifferenceFromNow(now, monthAgoTimestamp)).toEqual(30);
+ });
+
+ it('handles edge case with different times on same day', () => {
+ const now = new Date(Date.UTC(2023, 4, 5, 23, 59, 59)); // May 5, 2023 11:59:59 PM
+ const morningTimestamp = Math.floor(
+ new Date(Date.UTC(2023, 4, 5, 0, 0, 1)).getTime() / 1000
+ ); // May 5, 2023 12:00:01 AM
+
+ expect(getDayDifferenceFromNow(now, morningTimestamp)).toEqual(0);
+ });
+
+ it('handles cross-month boundaries correctly', () => {
+ const now = new Date(Date.UTC(2023, 4, 1, 12, 0, 0)); // May 1, 2023
+ const lastMonthTimestamp = Math.floor(
+ new Date(Date.UTC(2023, 3, 30, 12, 0, 0)).getTime() / 1000
+ ); // April 30, 2023
+
+ expect(getDayDifferenceFromNow(now, lastMonthTimestamp)).toEqual(1);
+ });
+
+ it('handles cross-year boundaries correctly', () => {
+ const now = new Date(Date.UTC(2023, 0, 2, 12, 0, 0)); // January 2, 2023
+ const lastYearTimestamp = Math.floor(
+ new Date(Date.UTC(2022, 11, 31, 12, 0, 0)).getTime() / 1000
+ ); // December 31, 2022
+
+ expect(getDayDifferenceFromNow(now, lastYearTimestamp)).toEqual(2);
+ });
+});
+
+describe('#hasOneDayPassed', () => {
+ beforeEach(() => {
+ // Mock current date: May 5, 2023, 12:00 PM UTC (1683288000)
+ const mockDate = new Date(1683288000 * 1000);
+ vi.setSystemTime(mockDate);
+ });
+
+ it('returns false for timestamps from today', () => {
+ // Same day, different time - May 5, 2023 8:00 AM UTC
+ const todayTimestamp = 1683273600;
+
+ expect(hasOneDayPassed(todayTimestamp)).toBe(false);
+ });
+
+ it('returns false for timestamps from yesterday (less than 24 hours)', () => {
+ // Yesterday but less than 24 hours ago - May 4, 2023 6:00 PM UTC (18 hours ago)
+ const yesterdayTimestamp = 1683230400;
+
+ expect(hasOneDayPassed(yesterdayTimestamp)).toBe(false);
+ });
+
+ it('returns true for timestamps from exactly 1 day ago', () => {
+ // Exactly 24 hours ago - May 4, 2023 12:00 PM UTC
+ const oneDayAgoTimestamp = 1683201600;
+
+ expect(hasOneDayPassed(oneDayAgoTimestamp)).toBe(true);
+ });
+
+ it('returns true for timestamps from more than 1 day ago', () => {
+ // 2 days ago - May 3, 2023 10:00 AM UTC
+ const twoDaysAgoTimestamp = 1683108000;
+
+ expect(hasOneDayPassed(twoDaysAgoTimestamp)).toBe(true);
+ });
+
+ it('returns true for timestamps from a week ago', () => {
+ // 7 days ago - April 28, 2023 8:00 AM UTC
+ const weekAgoTimestamp = 1682668800;
+
+ expect(hasOneDayPassed(weekAgoTimestamp)).toBe(true);
+ });
+
+ it('returns true for null timestamp (defensive check)', () => {
+ expect(hasOneDayPassed(null)).toBe(true);
+ });
+
+ it('returns true for undefined timestamp (defensive check)', () => {
+ expect(hasOneDayPassed(undefined)).toBe(true);
+ });
+
+ it('returns true for zero timestamp (defensive check)', () => {
+ expect(hasOneDayPassed(0)).toBe(true);
+ });
+
+ it('returns true for empty string timestamp (defensive check)', () => {
+ expect(hasOneDayPassed('')).toBe(true);
+ });
+
+ it('handles cross-month boundaries correctly', () => {
+ // Set current time to May 1, 2023 12:00 PM UTC (1682942400)
+ const mayFirst = new Date(1682942400 * 1000);
+ vi.setSystemTime(mayFirst);
+
+ // April 29, 2023 12:00 PM UTC (1682769600) - 2 days ago, crossing month boundary
+ const crossMonthTimestamp = 1682769600;
+
+ expect(hasOneDayPassed(crossMonthTimestamp)).toBe(true);
+ });
+
+ it('handles cross-year boundaries correctly', () => {
+ // Set current time to January 2, 2023 12:00 PM UTC (1672660800)
+ const newYear = new Date(1672660800 * 1000);
+ vi.setSystemTime(newYear);
+
+ // December 30, 2022 12:00 PM UTC (1672401600) - 3 days ago, crossing year boundary
+ const crossYearTimestamp = 1672401600;
+
+ expect(hasOneDayPassed(crossYearTimestamp)).toBe(true);
+ });
+});
diff --git a/app/javascript/shared/helpers/timeHelper.js b/app/javascript/shared/helpers/timeHelper.js
index 6e041c7ec..5347d2410 100644
--- a/app/javascript/shared/helpers/timeHelper.js
+++ b/app/javascript/shared/helpers/timeHelper.js
@@ -3,6 +3,7 @@ import {
isSameYear,
fromUnixTime,
formatDistanceToNow,
+ differenceInDays,
} from 'date-fns';
/**
@@ -91,3 +92,25 @@ export const shortTimestamp = (time, withAgo = false) => {
.replace(' years ago', `y${suffix}`);
return convertToShortTime;
};
+
+/**
+ * Calculates the difference in days between now and a given timestamp.
+ * @param {Date} now - Current date/time.
+ * @param {number} timestampInSeconds - Unix timestamp in seconds.
+ * @returns {number} Number of days difference.
+ */
+export const getDayDifferenceFromNow = (now, timestampInSeconds) => {
+ const date = new Date(timestampInSeconds * 1000);
+ return differenceInDays(now, date);
+};
+
+/**
+ * Checks if more than 24 hours have passed since a given timestamp.
+ * Useful for determining if retry/refresh actions should be disabled.
+ * @param {number} timestamp - Unix timestamp.
+ * @returns {boolean} True if more than 24 hours have passed.
+ */
+export const hasOneDayPassed = timestamp => {
+ if (!timestamp) return true; // Defensive check
+ return getDayDifferenceFromNow(new Date(), timestamp) >= 1;
+};
diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js
index bf8ec492b..c1f48f6f4 100644
--- a/app/javascript/shared/mixins/inboxMixin.js
+++ b/app/javascript/shared/mixins/inboxMixin.js
@@ -57,15 +57,15 @@ export default {
isALineChannel() {
return this.channelType === INBOX_TYPES.LINE;
},
+ isAVoiceChannel() {
+ return this.channelType === INBOX_TYPES.VOICE;
+ },
isAnEmailChannel() {
return this.channelType === INBOX_TYPES.EMAIL;
},
isATelegramChannel() {
return this.channelType === INBOX_TYPES.TELEGRAM;
},
- isAVoiceChannel() {
- return this.channelType === INBOX_TYPES.VOICE;
- },
isATwilioSMSChannel() {
const { medium: medium = '' } = this.inbox;
return this.isATwilioChannel && medium === 'sms';
diff --git a/app/javascript/v3/App.vue b/app/javascript/v3/App.vue
index 992c0b381..ef7107beb 100644
--- a/app/javascript/v3/App.vue
+++ b/app/javascript/v3/App.vue
@@ -15,8 +15,10 @@ export default {
setColorTheme() {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
this.theme = 'dark';
+ document.documentElement.classList.add('dark');
} else {
- this.theme = 'light ';
+ this.theme = 'light';
+ document.documentElement.classList.remove('dark');
}
},
listenToThemeChanges() {
@@ -25,8 +27,10 @@ export default {
mql.onchange = e => {
if (e.matches) {
this.theme = 'dark';
+ document.documentElement.classList.add('dark');
} else {
this.theme = 'light';
+ document.documentElement.classList.remove('dark');
}
};
},
diff --git a/app/javascript/v3/api/auth.js b/app/javascript/v3/api/auth.js
index a4793d3d0..146db4e07 100644
--- a/app/javascript/v3/api/auth.js
+++ b/app/javascript/v3/api/auth.js
@@ -13,6 +13,16 @@ export const login = async ({
}) => {
try {
const response = await wootAPI.post('auth/sign_in', credentials);
+
+ // Check if MFA is required
+ if (response.status === 206 && response.data.mfa_required) {
+ // Return MFA data instead of throwing error
+ return {
+ mfaRequired: true,
+ mfaToken: response.data.mfa_token,
+ };
+ }
+
setAuthCredentials(response);
clearLocalStorageOnLogout();
window.location = getLoginRedirectURL({
@@ -20,8 +30,17 @@ export const login = async ({
ssoConversationId,
user: response.data.data,
});
+ return null;
} catch (error) {
+ // Check if it's an MFA required response
+ if (error.response?.status === 206 && error.response?.data?.mfa_required) {
+ return {
+ mfaRequired: true,
+ mfaToken: error.response.data.mfa_token,
+ };
+ }
throwErrorMessage(error);
+ return null;
}
};
diff --git a/app/javascript/v3/views/auth/signup/Index.vue b/app/javascript/v3/views/auth/signup/Index.vue
index 508c755f5..86baff55c 100644
--- a/app/javascript/v3/views/auth/signup/Index.vue
+++ b/app/javascript/v3/views/auth/signup/Index.vue
@@ -62,7 +62,7 @@ export default {
-
{{ $t('REGISTER.HAVE_AN_ACCOUNT') }}
+
{{ $t('REGISTER.HAVE_AN_ACCOUNT') }}
{{ replaceInstallationName($t('LOGIN.TITLE')) }}
diff --git a/app/javascript/v3/views/login/Index.vue b/app/javascript/v3/views/login/Index.vue
index 4a51b3059..ff8022714 100644
--- a/app/javascript/v3/views/login/Index.vue
+++ b/app/javascript/v3/views/login/Index.vue
@@ -15,6 +15,7 @@ import FormInput from '../../components/Form/Input.vue';
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
import Spinner from 'shared/components/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
+import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
const ERROR_MESSAGES = {
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
@@ -29,6 +30,7 @@ export default {
GoogleOAuthButton,
Spinner,
NextButton,
+ MfaVerification,
},
props: {
ssoAuthToken: { type: String, default: '' },
@@ -58,6 +60,8 @@ export default {
hasErrored: false,
},
error: '',
+ mfaRequired: false,
+ mfaToken: null,
};
},
validations() {
@@ -87,8 +91,10 @@ export default {
this.submitLogin();
}
if (this.authError) {
- const message = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
- useAlert(this.$t(message));
+ const messageKey = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
+ // Use a method to get the translated text to avoid dynamic key warning
+ const translatedMessage = this.getTranslatedMessage(messageKey);
+ useAlert(translatedMessage);
// wait for idle state
this.requestIdleCallbackPolyfill(() => {
// Remove the error query param from the url
@@ -98,6 +104,18 @@ export default {
}
},
methods: {
+ getTranslatedMessage(key) {
+ // Avoid dynamic key warning by handling each case explicitly
+ switch (key) {
+ case 'LOGIN.OAUTH.NO_ACCOUNT_FOUND':
+ return this.$t('LOGIN.OAUTH.NO_ACCOUNT_FOUND');
+ case 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY':
+ return this.$t('LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY');
+ case 'LOGIN.API.UNAUTH':
+ default:
+ return this.$t('LOGIN.API.UNAUTH');
+ }
+ },
// TODO: Remove this when Safari gets wider support
// Ref: https://caniuse.com/requestidlecallback
//
@@ -140,7 +158,15 @@ export default {
};
login(credentials)
- .then(() => {
+ .then(result => {
+ // Check if MFA is required
+ if (result?.mfaRequired) {
+ this.loginApi.showLoading = false;
+ this.mfaRequired = true;
+ this.mfaToken = result.mfaToken;
+ return;
+ }
+
this.handleImpersonation();
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
})
@@ -163,6 +189,17 @@ export default {
this.submitLogin();
},
+ handleMfaVerified() {
+ // MFA verification successful, continue with login
+ this.handleImpersonation();
+ window.location = '/app';
+ },
+ handleMfaCancel() {
+ // User cancelled MFA, reset state
+ this.mfaRequired = false;
+ this.mfaToken = null;
+ this.credentials.password = '';
+ },
},
};
@@ -193,7 +230,19 @@ export default {
+
+
+
+
+
+
+
+ {{ headerText }}
+
+
+
+
+
+
+
+
diff --git a/app/javascript/widget/components/Availability/AvailabilityText.story.vue b/app/javascript/widget/components/Availability/AvailabilityText.story.vue
new file mode 100644
index 000000000..aafcb3556
--- /dev/null
+++ b/app/javascript/widget/components/Availability/AvailabilityText.story.vue
@@ -0,0 +1,217 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/widget/components/Availability/AvailabilityText.vue b/app/javascript/widget/components/Availability/AvailabilityText.vue
new file mode 100644
index 000000000..649224d20
--- /dev/null
+++ b/app/javascript/widget/components/Availability/AvailabilityText.vue
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+ {{ replyTimeMessage }}
+
+
+
+
+ {{
+ isOnline
+ ? replyTimeMessage
+ : t('TEAM_AVAILABILITY.BACK_AS_SOON_AS_POSSIBLE')
+ }}
+
+
+
+
+
+ {{ t('TEAM_AVAILABILITY.BACK_AS_SOON_AS_POSSIBLE') }}
+
+
+
+
+ {{ t('REPLY_TIME.BACK_IN_SOME_TIME') }}
+
+
+
+
+ {{ t('REPLY_TIME.BACK_TOMORROW') }}
+
+
+
+
+ {{
+ t('REPLY_TIME.BACK_ON_DAY', {
+ day: dayNames[nextSlot.config.dayOfWeek],
+ })
+ }}
+
+
+
+
+ {{
+ t('REPLY_TIME.BACK_IN_MINUTES', {
+ time: `${roundedMinutesUntilOpen}`,
+ })
+ }}
+
+
+
+
+ {{ t('REPLY_TIME.BACK_IN_HOURS', adjustedHoursUntilOpen) }}
+
+
+
+
+ {{
+ t('REPLY_TIME.BACK_AT_TIME', {
+ time: formattedOpeningTime,
+ })
+ }}
+
+
+
diff --git a/app/javascript/widget/components/ChatFooter.vue b/app/javascript/widget/components/ChatFooter.vue
index 841b37216..c85727a2b 100755
--- a/app/javascript/widget/components/ChatFooter.vue
+++ b/app/javascript/widget/components/ChatFooter.vue
@@ -6,7 +6,7 @@ import FooterReplyTo from 'widget/components/FooterReplyTo.vue';
import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { sendEmailTranscript } from 'widget/api/conversation';
-import routerMixin from 'widget/mixins/routerMixin';
+import { useRouter } from 'vue-router';
import { IFrameHelper } from '../helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
import { emitter } from 'shared/helpers/mitt';
@@ -17,7 +17,10 @@ export default {
CustomButton,
FooterReplyTo,
},
- mixins: [routerMixin],
+ setup() {
+ const router = useRouter();
+ return { router };
+ },
data() {
return {
inReplyTo: null,
@@ -55,15 +58,8 @@ export default {
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
},
methods: {
- ...mapActions('conversation', [
- 'sendMessage',
- 'sendAttachment',
- 'clearConversations',
- ]),
- ...mapActions('conversationAttributes', [
- 'getAttributes',
- 'clearConversationAttributes',
- ]),
+ ...mapActions('conversation', ['sendMessage', 'sendAttachment']),
+ ...mapActions('conversationAttributes', ['getAttributes']),
async handleSendMessage(content) {
await this.sendMessage({
content,
@@ -84,9 +80,7 @@ export default {
this.inReplyTo = null;
},
startNewConversation() {
- this.clearConversations();
- this.clearConversationAttributes();
- this.replaceRoute('prechat-form');
+ this.router.replace({ name: 'prechat-form' });
IFrameHelper.sendMessage({
event: 'onEvent',
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
diff --git a/app/javascript/widget/components/ChatHeader.vue b/app/javascript/widget/components/ChatHeader.vue
index 7c497b70f..578fb984e 100644
--- a/app/javascript/widget/components/ChatHeader.vue
+++ b/app/javascript/widget/components/ChatHeader.vue
@@ -1,55 +1,26 @@
-
@@ -79,9 +50,12 @@ export default {
${isOnline ? 'bg-n-teal-10' : 'hidden'}`"
/>
-