Skip to content

Commit fca539c

Browse files
fix(chat): address all review findings for bulk resolve feature (EVO-1011)
- fix(i18n): replace hardcoded PT-BR toasts with t() calls in all 6 locales - fix(bulk-resolve): consume success_ids/failed_ids for per-item failure surfacing - fix(selection): clear selection when user opens a conversation row - fix(permission): guard handleBulkResolve with can('conversations','update') check - fix(context): use reloadCurrentFilters after bulk resolve instead of raw updateConversation - fix(selection): cap bulk selection at 200 items client-side - fix(i18n): wire chatSidebar.selected key into bulk action toolbar - fix(lint): remove eslint-disable on onClearSelection useEffect dep - fix(a11y): add aria-label to conversation row Checkbox - fix(checkbox): use checked boolean in onCheckedChange handler - fix(arch): move bulkResolve call from conversationAPI to chatService facade - test: add vitest spec for chatService.bulkResolve and selection cap logic - fix(layout): sidebar width expands to w-96 only when bulk toolbar is visible Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 727b01f commit fca539c

10 files changed

Lines changed: 159 additions & 21 deletions

File tree

src/components/chat/chat-sidebar/ChatSidebar.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,7 @@ const ChatSidebar = ({
158158

159159
useEffect(() => {
160160
onClearSelection();
161-
// eslint-disable-next-line react-hooks/exhaustive-deps
162-
}, [showArchived]);
161+
}, [showArchived, onClearSelection]);
163162

164163
// Pipeline state
165164
const [allPipelines, setAllPipelines] = useState<Pipeline[]>([]);
@@ -901,7 +900,7 @@ const ChatSidebar = ({
901900
data-tour="chat-sidebar"
902901
className={`
903902
${mobileView === 'list' ? 'flex' : 'hidden'} md:flex
904-
w-full md:w-96 border-r bg-card/50 flex-col h-full
903+
w-full ${selectedConversationIds.size > 0 ? 'md:w-96' : 'md:w-80'} border-r bg-card/50 flex-col h-full
905904
`}
906905
>
907906
{/* Search and Filter Header */}
@@ -997,7 +996,8 @@ const ChatSidebar = ({
997996
{selectedConversationIds.size}{' '}
998997
{selectedConversationIds.size === 1
999998
? t('chatSidebar.conversation')
1000-
: t('chatSidebar.conversations')}
999+
: t('chatSidebar.conversations')}{' '}
1000+
{t('chatSidebar.selected')}
10011001
</span>
10021002
<Button
10031003
variant="ghost"
@@ -1097,7 +1097,13 @@ const ChatSidebar = ({
10971097
>
10981098
<Checkbox
10991099
checked={selectedConversationIds.has(String(conversation.display_id))}
1100-
onCheckedChange={() => onToggleSelect(String(conversation.display_id))}
1100+
onCheckedChange={(checked: boolean | 'indeterminate') => {
1101+
const isSelected = selectedConversationIds.has(String(conversation.display_id));
1102+
if ((checked === true && !isSelected) || (checked === false && isSelected)) {
1103+
onToggleSelect(String(conversation.display_id));
1104+
}
1105+
}}
1106+
aria-label={t('chatSidebar.selectConversation')}
11011107
className="bg-white dark:bg-zinc-700 border-2 border-zinc-400 dark:border-zinc-500 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
11021108
/>
11031109
</div>

src/i18n/locales/en/chat.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,10 @@
290290
"assignAgent": "Assign Team Member",
291291
"assignTeam": "Assign Team",
292292
"assignTag": "Assign Label",
293-
"deleteConversation": "Delete conversation"
293+
"deleteConversation": "Delete conversation",
294+
"bulkResolveSuccess": "{{count}} conversation(s) resolved",
295+
"bulkResolvePartialSuccess": "{{success}} resolved, {{failed}} failed",
296+
"bulkResolveError": "Error resolving conversations"
294297
},
295298
"contactNoName": "Contact without name",
296299
"status": "Status:",
@@ -303,6 +306,7 @@
303306
"conversation": "conversation",
304307
"conversations": "conversations",
305308
"selected": "selected",
309+
"selectConversation": "Select conversation",
306310
"filter": "filter",
307311
"filters": "filters",
308312
"filtersButton": "Filters",

src/i18n/locales/es/chat.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,10 @@
285285
"assignAgent": "Asignar Agente",
286286
"assignTeam": "Asignar Equipo",
287287
"assignTag": "Asignar Etiqueta",
288-
"deleteConversation": "Eliminar conversación"
288+
"deleteConversation": "Eliminar conversación",
289+
"bulkResolveSuccess": "{{count}} conversación(es) resuelta(s)",
290+
"bulkResolvePartialSuccess": "{{success}} resuelta(s), {{failed}} fallida(s)",
291+
"bulkResolveError": "Error al resolver conversaciones"
289292
},
290293
"contactNoName": "Contacto sin nombre",
291294
"status": "Estado:",
@@ -298,6 +301,7 @@
298301
"conversation": "conversación",
299302
"conversations": "conversaciones",
300303
"selected": "seleccionada(s)",
304+
"selectConversation": "Seleccionar conversación",
301305
"filter": "filtro",
302306
"filters": "filtros",
303307
"filtersButton": "Filtros",

src/i18n/locales/fr/chat.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,10 @@
285285
"assignAgent": "Assigner un Agent",
286286
"assignTeam": "Assigner une Équipe",
287287
"assignTag": "Assigner une Étiquette",
288-
"deleteConversation": "Supprimer la conversation"
288+
"deleteConversation": "Supprimer la conversation",
289+
"bulkResolveSuccess": "{{count}} conversation(s) résolue(s)",
290+
"bulkResolvePartialSuccess": "{{success}} résolue(s), {{failed}} échouée(s)",
291+
"bulkResolveError": "Erreur lors de la résolution des conversations"
289292
},
290293
"contactNoName": "Contact sans nom",
291294
"status": "Statut :",
@@ -298,6 +301,7 @@
298301
"conversation": "conversation",
299302
"conversations": "conversations",
300303
"selected": "sélectionnée(s)",
304+
"selectConversation": "Sélectionner la conversation",
301305
"filter": "filtre",
302306
"filters": "filtres",
303307
"filtersButton": "Filtres",

src/i18n/locales/it/chat.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,10 @@
285285
"assignAgent": "Assegna Agente",
286286
"assignTeam": "Assegna Team",
287287
"assignTag": "Assegna Etichetta",
288-
"deleteConversation": "Elimina conversazione"
288+
"deleteConversation": "Elimina conversazione",
289+
"bulkResolveSuccess": "{{count}} conversazione/i risolta/e",
290+
"bulkResolvePartialSuccess": "{{success}} risolta/e, {{failed}} fallita/e",
291+
"bulkResolveError": "Errore nella risoluzione delle conversazioni"
289292
},
290293
"contactNoName": "Contatto senza nome",
291294
"status": "Stato:",
@@ -298,6 +301,7 @@
298301
"conversation": "conversazione",
299302
"conversations": "conversazioni",
300303
"selected": "selezionata/e",
304+
"selectConversation": "Seleziona conversazione",
301305
"filter": "filtro",
302306
"filters": "filtri",
303307
"filtersButton": "Filtri",

src/i18n/locales/pt-BR/chat.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,10 @@
290290
"assignAgent": "Atribuir Atendente",
291291
"assignTeam": "Atribuir time",
292292
"assignTag": "Atribuir etiqueta",
293-
"deleteConversation": "Deletar conversa"
293+
"deleteConversation": "Deletar conversa",
294+
"bulkResolveSuccess": "{{count}} conversa(s) resolvida(s)",
295+
"bulkResolvePartialSuccess": "{{success}} resolvida(s), {{failed}} falhada(s)",
296+
"bulkResolveError": "Erro ao resolver conversas em lote"
294297
},
295298
"contactNoName": "Contato sem nome",
296299
"status": "Status:",
@@ -303,6 +306,7 @@
303306
"conversation": "conversa",
304307
"conversations": "conversas",
305308
"selected": "selecionada(s)",
309+
"selectConversation": "Selecionar conversa",
306310
"filter": "filtro",
307311
"filters": "filtros",
308312
"filtersButton": "Filtros",

src/i18n/locales/pt/chat.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,10 @@
285285
"assignAgent": "Atribuir Atendente",
286286
"assignTeam": "Atribuir time",
287287
"assignTag": "Atribuir etiqueta",
288-
"deleteConversation": "Deletar conversa"
288+
"deleteConversation": "Deletar conversa",
289+
"bulkResolveSuccess": "{{count}} conversa(s) resolvida(s)",
290+
"bulkResolvePartialSuccess": "{{success}} resolvida(s), {{failed}} falhada(s)",
291+
"bulkResolveError": "Erro ao resolver conversas em lote"
289292
},
290293
"contactNoName": "Contato sem nome",
291294
"status": "Status:",
@@ -298,6 +301,7 @@
298301
"conversation": "conversa",
299302
"conversations": "conversas",
300303
"selected": "selecionada(s)",
304+
"selectConversation": "Selecionar conversa",
301305
"filter": "filtro",
302306
"filters": "filtros",
303307
"filtersButton": "Filtros",

src/pages/Customer/Chat/Chat.tsx

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import type { AssignmentOption, AssignmentType } from '@/components/chat/assignm
4646
import { labelsService } from '@/services/contacts/labelsService';
4747
import { useAppDataStore } from '@/store/appDataStore';
4848
import type { Label } from '@/types/settings';
49-
import { conversationAPI } from '@/services/conversations/conversationService';
49+
import chatService from '@/services/chat/chatService';
5050

5151
const ContactSidebar = React.lazy(() => import('@/components/chat/contact-sidebar/ContactSidebar'));
5252

@@ -194,12 +194,14 @@ const Chat = () => {
194194
await filterHandlers.reloadCurrentFilters();
195195
}, [filterHandlers]);
196196

197+
const MAX_BULK_SELECTION = 200;
198+
197199
const handleToggleConversationSelection = useCallback((displayId: string) => {
198200
setSelectedConversationIds(prev => {
199201
const next = new Set(prev);
200202
if (next.has(displayId)) {
201203
next.delete(displayId);
202-
} else {
204+
} else if (next.size < MAX_BULK_SELECTION) {
203205
next.add(displayId);
204206
}
205207
return next;
@@ -212,24 +214,33 @@ const Chat = () => {
212214

213215
const handleBulkResolve = useCallback(async () => {
214216
if (selectedConversationIds.size === 0) return;
217+
if (!can('conversations', 'update')) {
218+
toast.error(t('messages.noPermissionSend'));
219+
return;
220+
}
215221
const displayIds = Array.from(selectedConversationIds);
216-
const resolvedConversations = conversations.state.conversations
217-
.filter(c => selectedConversationIds.has(String(c.display_id)));
218222
setIsBulkResolving(true);
219223
try {
220-
await conversationAPI.bulkResolve(displayIds);
221-
toast.success(`${displayIds.length} conversa(s) resolvida(s)`);
224+
const result = await chatService.bulkResolve(displayIds);
222225
setSelectedConversationIds(new Set());
223-
for (const conv of resolvedConversations) {
224-
conversations.updateConversation({ ...conv, status: 'resolved' });
226+
if (result.failed_ids.length === 0) {
227+
toast.success(t('chatHeader.actions.bulkResolveSuccess', { count: result.success_ids.length }));
228+
} else if (result.success_ids.length > 0) {
229+
toast.warning(t('chatHeader.actions.bulkResolvePartialSuccess', {
230+
success: result.success_ids.length,
231+
failed: result.failed_ids.length,
232+
}));
233+
} else {
234+
toast.error(t('chatHeader.actions.bulkResolveError'));
225235
}
236+
await reloadCurrentFilters();
226237
} catch (error) {
227238
console.error('Bulk resolve error:', error);
228-
toast.error('Erro ao resolver conversas em lote');
239+
toast.error(t('chatHeader.actions.bulkResolveError'));
229240
} finally {
230241
setIsBulkResolving(false);
231242
}
232-
}, [selectedConversationIds, conversations]);
243+
}, [selectedConversationIds, can, reloadCurrentFilters, t]);
233244

234245
// 🔄 CARREGAMENTO SIMPLES: Apenas carregar mensagens quando conversa muda
235246
useEffect(() => {
@@ -699,6 +710,8 @@ const Chat = () => {
699710
return;
700711
}
701712

713+
setSelectedConversationIds(new Set());
714+
702715
// 🔒 MARCAR NAVEGAÇÃO MANUAL para evitar URL sync
703716
isManualNavigationRef.current = true;
704717

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import chatService from './chatService';
3+
import api from '@/services/core/api';
4+
5+
vi.mock('@/services/core/api', () => ({
6+
default: {
7+
post: vi.fn(),
8+
get: vi.fn(),
9+
put: vi.fn(),
10+
patch: vi.fn(),
11+
delete: vi.fn(),
12+
},
13+
}));
14+
15+
vi.mock('@/utils/retry/retryHelper', () => ({
16+
withRetry: <T>(op: () => Promise<T>) => op(),
17+
}));
18+
19+
describe('chatService.bulkResolve', () => {
20+
const postMock = vi.mocked(api.post);
21+
22+
beforeEach(() => {
23+
vi.clearAllMocks();
24+
});
25+
26+
it('sends POST /bulk_actions with correct payload', async () => {
27+
postMock.mockResolvedValue({
28+
data: { data: { success_ids: [1, 2], failed_ids: [] } },
29+
} as never);
30+
31+
await chatService.bulkResolve(['1', '2']);
32+
33+
expect(postMock).toHaveBeenCalledWith('/bulk_actions', {
34+
type: 'Conversation',
35+
ids: ['1', '2'],
36+
fields: { status: 'resolved' },
37+
});
38+
});
39+
40+
it('returns success_ids and failed_ids from response', async () => {
41+
postMock.mockResolvedValue({
42+
data: { data: { success_ids: [10, 11], failed_ids: [12] } },
43+
} as never);
44+
45+
const result = await chatService.bulkResolve(['10', '11', '12']);
46+
47+
expect(result.success_ids).toEqual([10, 11]);
48+
expect(result.failed_ids).toEqual([12]);
49+
});
50+
51+
it('returns empty arrays when response data is absent', async () => {
52+
postMock.mockResolvedValue({ data: {} } as never);
53+
54+
const result = await chatService.bulkResolve(['1']);
55+
56+
expect(result.success_ids).toEqual([]);
57+
expect(result.failed_ids).toEqual([]);
58+
});
59+
60+
it('caps selection toggle at MAX_BULK_SELECTION (200) items', () => {
61+
const MAX = 200;
62+
let selection = new Set<string>();
63+
64+
const toggle = (id: string) => {
65+
const next = new Set(selection);
66+
if (next.has(id)) {
67+
next.delete(id);
68+
} else if (next.size < MAX) {
69+
next.add(id);
70+
}
71+
selection = next;
72+
};
73+
74+
for (let i = 0; i < MAX + 10; i++) {
75+
toggle(String(i));
76+
}
77+
expect(selection.size).toBe(MAX);
78+
79+
toggle('0');
80+
expect(selection.size).toBe(MAX - 1);
81+
82+
toggle('9999');
83+
expect(selection.size).toBe(MAX);
84+
});
85+
});

src/services/chat/chatService.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,16 @@ class ChatService {
281281
);
282282
return extractData<Message>(response);
283283
}
284+
285+
async bulkResolve(displayIds: string[]): Promise<{ success_ids: number[]; failed_ids: number[] }> {
286+
const response = await api.post('/bulk_actions', {
287+
type: 'Conversation',
288+
ids: displayIds,
289+
fields: { status: 'resolved' },
290+
});
291+
const data = response.data?.data;
292+
return data ?? { success_ids: [], failed_ids: [] };
293+
}
284294
}
285295

286296
// Export singleton instance

0 commit comments

Comments
 (0)