Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/ui-lib

This commit is contained in:
Shivam Mishra
2026-03-20 17:34:27 +05:30
2279 changed files with 77643 additions and 20821 deletions
@@ -96,7 +96,7 @@ const actions = {
data: payload,
});
if (!payload.length) {
commit(types.SET_ALL_MESSAGES_LOADED);
commit(types.SET_ALL_MESSAGES_LOADED, data.conversationId);
}
} catch (error) {
// Handle error
@@ -191,7 +191,7 @@ const actions = {
async setActiveChat({ commit, dispatch }, { data, after }) {
commit(types.SET_CURRENT_CHAT_WINDOW, data);
commit(types.CLEAR_ALL_MESSAGES_LOADED);
commit(types.CLEAR_ALL_MESSAGES_LOADED, data.id);
if (data.dataFetched === undefined) {
try {
await dispatch('fetchPreviousMessages', {
@@ -199,7 +199,7 @@ const actions = {
before: data.messages[0].id,
conversationId: data.id,
});
data.dataFetched = true;
commit(types.SET_CHAT_DATA_FETCHED, data.id);
} catch (error) {
// Ignore error
}
@@ -212,14 +212,17 @@ const actions = {
conversationId,
agentId,
});
dispatch('setCurrentChatAssignee', response.data);
dispatch('setCurrentChatAssignee', {
conversationId,
assignee: response.data,
});
} catch (error) {
// Handle error
}
},
setCurrentChatAssignee({ commit }, assignee) {
commit(types.ASSIGN_AGENT, assignee);
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
commit(types.ASSIGN_AGENT, { conversationId, assignee });
},
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
@@ -454,11 +457,7 @@ const actions = {
},
sendEmailTranscript: async (_, { conversationId, email }) => {
try {
await ConversationApi.sendEmailTranscript({ conversationId, email });
} catch (error) {
throw new Error(error);
}
await ConversationApi.sendEmailTranscript({ conversationId, email });
},
updateCustomAttributes: async (
@@ -116,6 +116,7 @@ const SORT_OPTIONS = {
priority_desc: ['sortOnPriority', 'desc'],
waiting_since_asc: ['sortOnWaitingSince', 'asc'],
waiting_since_desc: ['sortOnWaitingSince', 'desc'],
priority_desc_created_at_asc: ['sortOnPriorityCreatedAt', 'desc'],
};
const sortAscending = (valueA, valueB) => valueA - valueB;
const sortDescending = (valueA, valueB) => valueB - valueA;
@@ -139,6 +140,14 @@ const sortConfig = {
return getSortOrderFunction(sortDirection)(p1, p2);
},
sortOnPriorityCreatedAt: (a, b) => {
const DEFAULT_FOR_NULL = 0;
const p1 = CONVERSATION_PRIORITY_ORDER[a.priority] || DEFAULT_FOR_NULL;
const p2 = CONVERSATION_PRIORITY_ORDER[b.priority] || DEFAULT_FOR_NULL;
if (p1 !== p2) return p2 - p1;
return a.created_at - b.created_at;
},
sortOnWaitingSince: (a, b, sortDirection) => {
const sortFunc = getSortOrderFunction(sortDirection);
if (!a.waiting_since || !b.waiting_since) {
@@ -63,14 +63,18 @@ export const mutations = {
_state.allConversations = [];
_state.selectedChatId = null;
},
[types.SET_ALL_MESSAGES_LOADED](_state) {
const [chat] = getSelectedChatConversation(_state);
chat.allMessagesLoaded = true;
[types.SET_ALL_MESSAGES_LOADED](_state, conversationId) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.allMessagesLoaded = true;
}
},
[types.CLEAR_ALL_MESSAGES_LOADED](_state) {
const [chat] = getSelectedChatConversation(_state);
chat.allMessagesLoaded = false;
[types.CLEAR_ALL_MESSAGES_LOADED](_state, conversationId) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.allMessagesLoaded = false;
}
},
[types.CLEAR_CURRENT_CHAT_WINDOW](_state) {
_state.selectedChatId = null;
@@ -91,15 +95,24 @@ export const mutations = {
chat.messages = data;
},
[types.SET_CHAT_DATA_FETCHED](_state, conversationId) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.dataFetched = true;
}
},
[types.SET_CURRENT_CHAT_WINDOW](_state, activeChat) {
if (activeChat) {
_state.selectedChatId = activeChat.id;
}
},
[types.ASSIGN_AGENT](_state, assignee) {
const [chat] = getSelectedChatConversation(_state);
chat.meta.assignee = assignee;
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.meta.assignee = assignee;
}
},
[types.ASSIGN_TEAM](_state, { team, conversationId }) {
@@ -215,7 +228,10 @@ export const mutations = {
},
[types.ADD_CONVERSATION](_state, conversation) {
_state.allConversations.push(conversation);
const exists = _state.allConversations.some(c => c.id === conversation.id);
if (!exists) {
_state.allConversations.push(conversation);
}
},
[types.DELETE_CONVERSATION](_state, conversationId) {
@@ -274,8 +290,10 @@ export const mutations = {
// Update assignee on action cable message
[types.UPDATE_ASSIGNEE](_state, payload) {
const [chat] = _state.allConversations.filter(c => c.id === payload.id);
chat.meta.assignee = payload.assignee;
const chat = getConversationById(_state)(payload.id);
if (chat) {
chat.meta.assignee = payload.assignee;
}
},
[types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) {
@@ -167,7 +167,17 @@ export const actions = {
return fileUrl;
},
reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => {
reorder: async (
{ commit, state },
{ portalSlug, categorySlug, reorderedGroup }
) => {
// Save old positions so we can rollback on failure
const oldPositions = Object.keys(reorderedGroup).reduce((map, id) => {
map[id] = state.articles.byId[id]?.position;
return map;
}, {});
// Update positions in the store immediately so subsequent mutations preserve correct positions
commit(types.SET_ARTICLE_POSITIONS, reorderedGroup);
try {
await articlesAPI.reorderArticles({
portalSlug,
@@ -175,9 +185,8 @@ export const actions = {
categorySlug,
});
} catch (error) {
throwErrorMessage(error);
commit(types.SET_ARTICLE_POSITIONS, oldPositions);
throw error;
}
return '';
},
};
@@ -22,6 +22,16 @@ export const getters = {
.filter(article => article !== undefined);
return articles;
},
allArticlesSortedByPosition: (...getterArguments) => {
const [state, _getters] = getterArguments;
const articles = state.articles.allIds
.map(id => _getters.articleById(id))
.filter(article => article !== undefined);
// Sort by position so reordered articles stay in correct order after store updates
return articles.sort(
(a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)
);
},
articleStatus:
(...getterArguments) =>
articleId => {
@@ -64,6 +64,18 @@ export const mutations = {
...uiFlags,
};
},
[types.SET_ARTICLE_POSITIONS]: ($state, positionsHash) => {
const { byId, allIds } = $state.articles;
// Update position on each article record
Object.entries(positionsHash).forEach(([id, position]) => {
if (byId[id]) byId[id] = { ...byId[id], position };
});
// Re-sort allIds so every consumer sees the new order
allIds.sort(
(a, b) =>
(byId[a]?.position ?? Infinity) - (byId[b]?.position ?? Infinity)
);
},
[types.UPDATE_ARTICLE]: ($state, updatedArticle) => {
const articleId = updatedArticle.id;
if ($state.articles.byId[articleId]) {
@@ -279,4 +279,63 @@ describe('#actions', () => {
).rejects.toThrow('Upload failed');
});
});
describe('#reorder', () => {
const state = {
articles: {
byId: {
1: { id: 1, title: 'Article 1', position: 10 },
2: { id: 2, title: 'Article 2', position: 20 },
3: { id: 3, title: 'Article 3', position: 30 },
},
},
};
it('commits SET_ARTICLE_POSITIONS and calls API when reorder is successful', async () => {
axios.post.mockResolvedValue({ data: {} });
const reorderedGroup = { 1: 1, 2: 2, 3: 3 };
await actions.reorder(
{ commit, state },
{
portalSlug: 'test-portal',
categorySlug: 'test-category',
reorderedGroup,
}
);
expect(commit).toHaveBeenCalledWith(
types.default.SET_ARTICLE_POSITIONS,
reorderedGroup
);
expect(axios.post).toHaveBeenCalledWith(
expect.stringContaining('/portals/test-portal/articles/reorder'),
{ positions_hash: reorderedGroup, category_slug: 'test-category' }
);
});
it('rolls back positions and throws when API call fails', async () => {
axios.post.mockRejectedValue({ message: 'Network error' });
const reorderedGroup = { 1: 1, 2: 2 };
await expect(
actions.reorder(
{ commit, state },
{
portalSlug: 'test-portal',
reorderedGroup,
}
)
).rejects.toEqual({ message: 'Network error' });
expect(commit).toHaveBeenCalledWith(
types.default.SET_ARTICLE_POSITIONS,
reorderedGroup
);
expect(commit).toHaveBeenCalledWith(types.default.SET_ARTICLE_POSITIONS, {
1: 10,
2: 20,
});
});
});
});
@@ -41,4 +41,82 @@ describe('#getters', () => {
it('isFetchingArticles', () => {
expect(getters.isFetching(state)).toEqual(true);
});
describe('allArticlesSortedByPosition', () => {
it('returns articles sorted by position in ascending order', () => {
const stateWithPositions = {
...state,
articles: {
...state.articles,
byId: {
1: { id: 1, title: 'Article 1', position: 3 },
2: { id: 2, title: 'Article 2', position: 1 },
3: { id: 3, title: 'Article 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
articleById: getters.articleById(stateWithPositions),
};
const result = getters.allArticlesSortedByPosition(
stateWithPositions,
boundGetters
);
expect(result.map(a => a.id)).toEqual([2, 3, 1]);
expect(result.map(a => a.position)).toEqual([1, 2, 3]);
});
it('places articles with null position at the end', () => {
const stateWithNullPositions = {
...state,
articles: {
...state.articles,
byId: {
1: { id: 1, title: 'Article 1', position: 1 },
2: { id: 2, title: 'Article 2', position: null },
3: { id: 3, title: 'Article 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
articleById: getters.articleById(stateWithNullPositions),
};
const result = getters.allArticlesSortedByPosition(
stateWithNullPositions,
boundGetters
);
expect(result.map(a => a.id)).toEqual([1, 3, 2]);
});
it('handles articles with undefined position', () => {
const stateWithUndefinedPositions = {
...state,
articles: {
...state.articles,
byId: {
1: { id: 1, title: 'Article 1', position: 1 },
2: { id: 2, title: 'Article 2' },
3: { id: 3, title: 'Article 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
articleById: getters.articleById(stateWithUndefinedPositions),
};
const result = getters.allArticlesSortedByPosition(
stateWithUndefinedPositions,
boundGetters
);
expect(result.map(a => a.id)).toEqual([1, 3, 2]);
});
});
});
@@ -5,7 +5,7 @@ import types from '../../../mutation-types';
describe('#mutations', () => {
let state = {};
beforeEach(() => {
state = article;
state = JSON.parse(JSON.stringify(article));
});
describe('#SET_UI_FLAG', () => {
@@ -93,9 +93,9 @@ describe('#mutations', () => {
mutations[types.ADD_ARTICLE_ID](state, 3);
expect(state.articles.allIds).toEqual([1, 2, 3]);
});
it('Does not invalid article with empty data passed', () => {
mutations[types.ADD_ARTICLE_ID](state, {});
expect(state).toEqual(article);
it('does not add duplicate article id to state', () => {
mutations[types.ADD_ARTICLE_ID](state, 1);
expect(state.articles.allIds).toEqual([1, 2]);
});
});
@@ -154,4 +154,53 @@ describe('#mutations', () => {
});
});
});
describe('#SET_ARTICLE_POSITIONS', () => {
it('updates positions for articles in the store', () => {
const positionsHash = { 1: 1, 2: 2 };
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
expect(state.articles.byId[1].position).toEqual(1);
expect(state.articles.byId[2].position).toEqual(2);
});
it('does not update articles that are not in the store', () => {
const positionsHash = { 999: 5 };
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
expect(state.articles.byId[999]).toBeUndefined();
});
it('preserves other article properties when updating position', () => {
const originalTitle = state.articles.byId[1].title;
const positionsHash = { 1: 3 };
mutations[types.SET_ARTICLE_POSITIONS](state, positionsHash);
expect(state.articles.byId[1].position).toEqual(3);
expect(state.articles.byId[1].title).toEqual(originalTitle);
});
it('re-sorts allIds by position after update', () => {
state.articles.byId[1].position = 1;
state.articles.byId[2].position = 2;
state.articles.allIds = [1, 2];
mutations[types.SET_ARTICLE_POSITIONS](state, { 1: 3, 2: 1 });
expect(state.articles.allIds).toEqual([2, 1]);
});
it('UPDATE_ARTICLE preserves reordered position after SET_ARTICLE_POSITIONS', () => {
mutations[types.SET_ARTICLE_POSITIONS](state, { 2: 1 });
expect(state.articles.byId[2].position).toEqual(1);
mutations[types.UPDATE_ARTICLE](state, {
id: 2,
title: 'Updated Title',
status: 'published',
});
expect(state.articles.byId[2].position).toEqual(1);
expect(state.articles.byId[2].title).toEqual('Updated Title');
});
});
});
@@ -92,4 +92,23 @@ export const actions = {
});
}
},
reorder: async ({ commit, state }, { portalSlug, reorderedGroup }) => {
// Save old positions so we can rollback on failure
const oldPositions = Object.keys(reorderedGroup).reduce((map, id) => {
map[id] = state.categories.byId[id]?.position;
return map;
}, {});
// Update positions in the store immediately so subsequent mutations preserve correct positions
commit(types.SET_CATEGORY_POSITIONS, reorderedGroup);
try {
await categoriesAPI.reorder({
portalSlug,
reorderedGroup,
});
} catch (error) {
commit(types.SET_CATEGORY_POSITIONS, oldPositions);
throw error;
}
},
};
@@ -21,6 +21,16 @@ export const getters = {
});
return categories;
},
allCategoriesSortedByPosition: (...getterArguments) => {
const [state, _getters] = getterArguments;
const categories = state.categories.allIds
.map(id => _getters.categoryById(id))
.filter(category => category !== undefined);
// Sort by position so reordered categories stay in correct order after store updates
return categories.sort(
(a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)
);
},
categoriesByLocaleCode:
(...getterArguments) =>
localeCode => {
@@ -49,6 +49,18 @@ export const mutations = {
...uiFlags,
};
},
[types.SET_CATEGORY_POSITIONS]: ($state, positionsHash) => {
const { byId, allIds } = $state.categories;
// Update position on each category record
Object.entries(positionsHash).forEach(([id, position]) => {
if (byId[id]) byId[id] = { ...byId[id], position };
});
// Re-sort allIds so every consumer sees the new order
allIds.sort(
(a, b) =>
(byId[a]?.position ?? Infinity) - (byId[b]?.position ?? Infinity)
);
},
[types.UPDATE_CATEGORY]($state, category) {
const categoryId = category.id;
@@ -161,4 +161,63 @@ describe('#actions', () => {
]);
});
});
describe('#reorder', () => {
const state = {
categories: {
byId: {
1: { id: 1, name: 'Category 1', position: 10 },
2: { id: 2, name: 'Category 2', position: 20 },
},
},
};
it('commits SET_CATEGORY_POSITIONS and calls API when reorder is successful', async () => {
axios.post.mockResolvedValue({ data: {} });
const reorderedGroup = { 2: 1, 1: 2 };
await actions.reorder(
{ commit, state },
{
portalSlug: 'room-rental',
reorderedGroup,
}
);
expect(commit).toHaveBeenCalledWith(
types.default.SET_CATEGORY_POSITIONS,
reorderedGroup
);
expect(axios.post).toHaveBeenCalledWith(
expect.stringContaining('/portals/room-rental/categories/reorder'),
{
positions_hash: { 2: 1, 1: 2 },
}
);
});
it('rolls back positions and throws when API call fails', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
const reorderedGroup = { 2: 1, 1: 2 };
await expect(
actions.reorder(
{ commit, state },
{
portalSlug: 'room-rental',
reorderedGroup,
}
)
).rejects.toEqual({ message: 'Incorrect header' });
expect(commit).toHaveBeenCalledWith(
types.default.SET_CATEGORY_POSITIONS,
reorderedGroup
);
expect(commit).toHaveBeenCalledWith(
types.default.SET_CATEGORY_POSITIONS,
{ 1: 10, 2: 20 }
);
});
});
});
@@ -25,4 +25,82 @@ describe('#getters', () => {
it('isFetchingCategories', () => {
expect(getters.isFetching(state)).toEqual(true);
});
describe('allCategoriesSortedByPosition', () => {
it('returns categories sorted by position in ascending order', () => {
const stateWithPositions = {
...state,
categories: {
...state.categories,
byId: {
1: { id: 1, name: 'Category 1', position: 3 },
2: { id: 2, name: 'Category 2', position: 1 },
3: { id: 3, name: 'Category 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
categoryById: getters.categoryById(stateWithPositions),
};
const result = getters.allCategoriesSortedByPosition(
stateWithPositions,
boundGetters
);
expect(result.map(c => c.id)).toEqual([2, 3, 1]);
expect(result.map(c => c.position)).toEqual([1, 2, 3]);
});
it('places categories with null position at the end', () => {
const stateWithNullPositions = {
...state,
categories: {
...state.categories,
byId: {
1: { id: 1, name: 'Category 1', position: 1 },
2: { id: 2, name: 'Category 2', position: null },
3: { id: 3, name: 'Category 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
categoryById: getters.categoryById(stateWithNullPositions),
};
const result = getters.allCategoriesSortedByPosition(
stateWithNullPositions,
boundGetters
);
expect(result.map(c => c.id)).toEqual([1, 3, 2]);
});
it('handles categories with undefined position', () => {
const stateWithUndefinedPositions = {
...state,
categories: {
...state.categories,
byId: {
1: { id: 1, name: 'Category 1', position: 1 },
2: { id: 2, name: 'Category 2' },
3: { id: 3, name: 'Category 3', position: 2 },
},
allIds: [1, 2, 3],
},
};
const boundGetters = {
categoryById: getters.categoryById(stateWithUndefinedPositions),
};
const result = getters.allCategoriesSortedByPosition(
stateWithUndefinedPositions,
boundGetters
);
expect(result.map(c => c.id)).toEqual([1, 3, 2]);
});
});
});
@@ -4,7 +4,7 @@ import { categoriesState, categoriesPayload } from './fixtures';
describe('#mutations', () => {
let state = {};
beforeEach(() => {
state = categoriesState;
state = JSON.parse(JSON.stringify(categoriesState));
});
describe('#SET_UI_FLAG', () => {
@@ -53,9 +53,9 @@ describe('#mutations', () => {
mutations[types.ADD_CATEGORY_ID](state, 3);
expect(state.categories.allIds).toEqual([1, 2, 3]);
});
it('Does not invalid category with empty data passed', () => {
it('pushes the given id to allIds', () => {
mutations[types.ADD_CATEGORY_ID](state, {});
expect(state).toEqual(categoriesState);
expect(state.categories.allIds).toEqual([1, 2, {}]);
});
});
@@ -98,4 +98,40 @@ describe('#mutations', () => {
// expect(state.categories.uiFlags).toEqual({});
// });
// });
describe('#SET_CATEGORY_POSITIONS', () => {
it('updates positions for categories in the store', () => {
const positionsHash = { 1: 1, 2: 2 };
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
expect(state.categories.byId[1].position).toEqual(1);
expect(state.categories.byId[2].position).toEqual(2);
});
it('does not update categories that are not in the store', () => {
const positionsHash = { 999: 5 };
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
expect(state.categories.byId[999]).toBeUndefined();
});
it('preserves other category properties when updating position', () => {
const originalName = state.categories.byId[1].name;
const positionsHash = { 1: 3 };
mutations[types.SET_CATEGORY_POSITIONS](state, positionsHash);
expect(state.categories.byId[1].position).toEqual(3);
expect(state.categories.byId[1].name).toEqual(originalName);
});
it('re-sorts allIds by position after update', () => {
state.categories.byId[1].position = 1;
state.categories.byId[2].position = 2;
state.categories.allIds = [1, 2];
mutations[types.SET_CATEGORY_POSITIONS](state, { 1: 3, 2: 1 });
expect(state.categories.allIds).toEqual([2, 1]);
});
});
});
@@ -360,6 +360,13 @@ export const actions = {
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
return response.data;
},
analyzeCSATTemplateUtility: async (_, { inboxId, template }) => {
const response = await InboxesAPI.analyzeCSATTemplateUtility(
inboxId,
template
);
return response.data;
},
};
export const mutations = {
@@ -355,22 +355,26 @@ describe('#actions', () => {
axios.post.mockResolvedValue({
data: { id: 1, name: 'User' },
});
await actions.assignAgent({ commit }, { conversationId: 1, agentId: 1 });
expect(commit).toHaveBeenCalledTimes(0);
expect(commit.mock.calls).toEqual([]);
await actions.assignAgent(
{ dispatch },
{ conversationId: 1, agentId: 1 }
);
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
conversationId: 1,
assignee: { id: 1, name: 'User' },
});
});
});
describe('#setCurrentChatAssignee', () => {
it('sends correct mutations if assignment is successful', async () => {
axios.post.mockResolvedValue({
data: { id: 1, name: 'User' },
});
await actions.setCurrentChatAssignee({ commit }, { id: 1, name: 'User' });
const payload = {
conversationId: 1,
assignee: { id: 1, name: 'User' },
};
await actions.setCurrentChatAssignee({ commit }, payload);
expect(commit).toHaveBeenCalledTimes(1);
expect(commit.mock.calls).toEqual([
['ASSIGN_AGENT', { id: 1, name: 'User' }],
]);
expect(commit.mock.calls).toEqual([['ASSIGN_AGENT', payload]]);
});
});
@@ -716,6 +720,64 @@ describe('#addMentions', () => {
});
});
describe('#setActiveChat', () => {
it('should commit SET_CHAT_DATA_FETCHED with conversation ID after fetch', async () => {
const localCommit = vi.fn();
const localDispatch = vi.fn().mockResolvedValue();
const data = { id: 42, messages: [{ id: 100 }] };
await actions.setActiveChat(
{ commit: localCommit, dispatch: localDispatch },
{ data, after: 99 }
);
expect(localCommit.mock.calls).toEqual([
[types.SET_CURRENT_CHAT_WINDOW, data],
[types.CLEAR_ALL_MESSAGES_LOADED, 42],
[types.SET_CHAT_DATA_FETCHED, 42],
]);
expect(localDispatch).toHaveBeenCalledWith('fetchPreviousMessages', {
after: 99,
before: 100,
conversationId: 42,
});
});
it('should not dispatch fetchPreviousMessages if dataFetched is already set', async () => {
const localCommit = vi.fn();
const localDispatch = vi.fn();
const data = { id: 42, messages: [{ id: 100 }], dataFetched: true };
await actions.setActiveChat(
{ commit: localCommit, dispatch: localDispatch },
{ data }
);
expect(localCommit.mock.calls).toEqual([
[types.SET_CURRENT_CHAT_WINDOW, data],
[types.CLEAR_ALL_MESSAGES_LOADED, 42],
]);
expect(localDispatch).not.toHaveBeenCalled();
});
it('should commit SET_CHAT_DATA_FETCHED by ID, not mutate the data object directly (race condition fix)', async () => {
const localCommit = vi.fn();
const localDispatch = vi.fn().mockResolvedValue();
const data = { id: 42, messages: [{ id: 100 }] };
await actions.setActiveChat(
{ commit: localCommit, dispatch: localDispatch },
{ data }
);
// The action must NOT set dataFetched on the data object directly
expect(data.dataFetched).toBeUndefined();
// Instead it commits a mutation that finds the conversation by ID in the store
expect(localCommit).toHaveBeenCalledWith(types.SET_CHAT_DATA_FETCHED, 42);
});
});
describe('#getInboxCaptainAssistantById', () => {
it('fetches inbox assistant by id', async () => {
axios.get.mockResolvedValue({
@@ -570,25 +570,84 @@ describe('#mutations', () => {
});
});
describe('#SET_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to true on selected chat', () => {
describe('#SET_CHAT_DATA_FETCHED', () => {
it('should set dataFetched to true on the conversation by ID', () => {
const state = {
allConversations: [{ id: 1, allMessagesLoaded: false }],
allConversations: [{ id: 1 }, { id: 2 }],
};
mutations[types.SET_CHAT_DATA_FETCHED](state, 1);
expect(state.allConversations[0].dataFetched).toBe(true);
expect(state.allConversations[1].dataFetched).toBeUndefined();
});
it('should do nothing if conversation is not found', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.SET_CHAT_DATA_FETCHED](state, 999);
expect(state.allConversations[0].dataFetched).toBeUndefined();
});
it('should survive the race: SET_ALL_CONVERSATION replaces the object, then SET_CHAT_DATA_FETCHED still works', () => {
// 1. Initial state: conversation exists with dataFetched undefined
const state = {
allConversations: [{ id: 1, messages: [{ id: 'm1' }] }],
selectedChatId: 1,
};
mutations[types.SET_ALL_MESSAGES_LOADED](state);
const originalRef = state.allConversations[0];
// 2. Simulate SET_ALL_CONVERSATION replacing the object (WebSocket/polling)
// This copies dataFetched from the old object (still undefined)
mutations[types.SET_ALL_CONVERSATION](state, [
{ id: 1, name: 'refreshed', messages: [{ id: 'm2' }] },
]);
// The store now holds a NEW object, old reference is detached
const newRef = state.allConversations[0];
expect(newRef).not.toBe(originalRef);
expect(newRef.dataFetched).toBeUndefined();
// 3. SET_CHAT_DATA_FETCHED finds by ID — works on the current store object
mutations[types.SET_CHAT_DATA_FETCHED](state, 1);
expect(state.allConversations[0].dataFetched).toBe(true);
// Old detached reference is unaffected
expect(originalRef.dataFetched).toBeUndefined();
});
});
describe('#SET_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to true on the conversation by ID', () => {
const state = {
allConversations: [{ id: 1, allMessagesLoaded: false }, { id: 2 }],
};
mutations[types.SET_ALL_MESSAGES_LOADED](state, 1);
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
expect(state.allConversations[1].allMessagesLoaded).toBeUndefined();
});
it('should do nothing if conversation is not found', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.SET_ALL_MESSAGES_LOADED](state, 999);
expect(state.allConversations[0].allMessagesLoaded).toBeUndefined();
});
});
describe('#CLEAR_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to false on selected chat', () => {
it('should set allMessagesLoaded to false on the conversation by ID', () => {
const state = {
allConversations: [{ id: 1, allMessagesLoaded: true }],
selectedChatId: 1,
allConversations: [
{ id: 1, allMessagesLoaded: true },
{ id: 2, allMessagesLoaded: true },
],
};
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state);
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state, 1);
expect(state.allConversations[0].allMessagesLoaded).toBe(false);
expect(state.allConversations[1].allMessagesLoaded).toBe(true);
});
it('should do nothing if conversation is not found', () => {
const state = { allConversations: [{ id: 1, allMessagesLoaded: true }] };
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state, 999);
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
});
});
@@ -640,15 +699,22 @@ describe('#mutations', () => {
});
describe('#ASSIGN_AGENT', () => {
it('should assign agent to selected conversation', () => {
it('should assign agent to the correct conversation by ID', () => {
const assignee = { id: 1, name: 'Agent' };
const state = {
allConversations: [{ id: 1, meta: {} }],
selectedChatId: 1,
allConversations: [
{ id: 1, meta: {} },
{ id: 2, meta: {} },
],
selectedChatId: 2,
};
mutations[types.ASSIGN_AGENT](state, assignee);
mutations[types.ASSIGN_AGENT](state, {
conversationId: 1,
assignee,
});
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
expect(state.allConversations[1].meta.assignee).toBeUndefined();
});
});
@@ -797,6 +863,34 @@ describe('#mutations', () => {
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0].status).toEqual('resolved');
});
it('should preserve dataFetched and allMessagesLoaded during update', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 100,
messages: [{ id: 'msg1' }],
dataFetched: true,
allMessagesLoaded: true,
},
],
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 200,
messages: [{ id: 'msg2' }],
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0].status).toEqual('resolved');
expect(state.allConversations[0].dataFetched).toBe(true);
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
expect(state.allConversations[0].messages).toEqual([{ id: 'msg1' }]);
});
});
describe('#UPDATE_CONVERSATION_CONTACT', () => {
@@ -881,6 +975,16 @@ describe('#mutations', () => {
mutations[types.ADD_CONVERSATION](state, conversation);
expect(state.allConversations).toEqual([conversation]);
});
it('should not add a duplicate conversation', () => {
const conversation = { id: 1, messages: [] };
const state = {
allConversations: [conversation],
};
mutations[types.ADD_CONVERSATION](state, { id: 1, messages: [] });
expect(state.allConversations).toHaveLength(1);
});
});
describe('#DELETE_CONVERSATION', () => {
@@ -42,6 +42,7 @@ export const actions = {
} = response.data;
commit(types.default.ADD_WEBHOOK, webhook);
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
return webhook;
} catch (error) {
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
throw error;