Skip to content

Commit c899956

Browse files
fix(chat): pipeline actions in 3-dot menu and context menu (EVO-990)
- Add pipeline submenu to ChatHeader 3-dot dropdown and ChatSidebar context menu - Fix C1: remove from old pipeline before adding to new pipeline - Fix H1: addItemToPipeline uses conversation.id, not item.id - Fix M1: moveItem/removeItemFromPipeline use pipeline item.id, not conversation.id - Fix M2: separate toast keys for add vs move errors (pipeline.addError/moveError) - Fix M3/M7: always reload conv pipeline state after actions - Fix M4: delete dead PipelineManagement component and its spec - Fix M5: loadError retry in pipeline submenu - Fix M6: distinguish loading from empty pipeline state (isPipelinesLoaded flag) - Fix L2: move pipeline i18n keys to root pipeline.* namespace in all 6 locales - Fix L5: stale-response guard via monotonic fetchId counter ref - Add ChatHeader.spec.tsx with 5 tests covering H1, M1, M2, C1 behaviors - Remove duplicate PipelineManagement block from ContactSidebar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c42fec0 commit c899956

12 files changed

Lines changed: 884 additions & 733 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { vi, describe, it, expect, beforeEach } from 'vitest';
4+
import ChatHeader from './ChatHeader';
5+
import { pipelinesService } from '@/services/pipelines/pipelinesService';
6+
import chatService from '@/services/chat/chatService';
7+
import { toast } from 'sonner';
8+
9+
vi.mock('@/services/pipelines/pipelinesService', () => ({
10+
pipelinesService: {
11+
getPipelines: vi.fn(),
12+
getPipelinesByConversation: vi.fn(),
13+
addItemToPipeline: vi.fn(),
14+
moveItem: vi.fn(),
15+
removeItemFromPipeline: vi.fn(),
16+
},
17+
}));
18+
19+
vi.mock('@/services/chat/chatService', () => ({
20+
default: {
21+
getConversation: vi.fn(),
22+
},
23+
}));
24+
25+
vi.mock('sonner', () => ({
26+
toast: { success: vi.fn(), error: vi.fn() },
27+
}));
28+
29+
vi.mock('@/hooks/useLanguage', () => ({
30+
useLanguage: () => ({ t: (key: string) => key }),
31+
}));
32+
33+
vi.mock('@/utils/chat/conversationStatus', () => ({
34+
getStatusLabel: (s: string) => s,
35+
isPendingStatus: () => false,
36+
}));
37+
38+
vi.mock('@/utils/channelUtils', () => ({
39+
isPhoneBearingChannel: () => false,
40+
}));
41+
42+
vi.mock('@/utils/contact/formatContactPhone', () => ({
43+
formatContactPhone: (p: string) => p,
44+
}));
45+
46+
vi.mock('@/components/chat/contact/ContactAvatar', () => ({
47+
default: () => <div data-testid="contact-avatar" />,
48+
}));
49+
50+
const makePipeline = (
51+
id: string,
52+
stages: { id: string; name: string }[],
53+
items: { id: string; item_id: string; stage_id: string }[] = [],
54+
) => ({
55+
id,
56+
name: `Pipeline ${id}`,
57+
pipeline_type: 'custom' as const,
58+
visibility: 'public' as const,
59+
is_active: true,
60+
stages: stages.map(s => ({ ...s, color: '#000', position: 0, created_at: '', updated_at: '' })),
61+
items,
62+
created_at: '',
63+
updated_at: '',
64+
});
65+
66+
const makeConversation = (id = '42') =>
67+
({
68+
id,
69+
status: 'open' as const,
70+
inbox: { id: '1', name: 'WhatsApp', channel_type: 'Channel::Whatsapp' },
71+
contact: { id: '1', name: 'Test Contact' },
72+
custom_attributes: {},
73+
}) as never;
74+
75+
const defaultProps = {
76+
conversation: makeConversation(),
77+
onBackClick: vi.fn(),
78+
onCloseConversation: vi.fn(),
79+
onContactSidebarOpen: vi.fn(),
80+
onMarkAsRead: vi.fn(),
81+
onMarkAsUnread: vi.fn(),
82+
onMarkAsOpen: vi.fn(),
83+
onMarkAsResolved: vi.fn(),
84+
onPostpone: vi.fn(),
85+
onMarkAsSnoozed: vi.fn(),
86+
onSetPriority: vi.fn(),
87+
onPinConversation: vi.fn(),
88+
onUnpinConversation: vi.fn(),
89+
onArchiveConversation: vi.fn(),
90+
onUnarchiveConversation: vi.fn(),
91+
onAssignAgent: vi.fn(),
92+
onAssignTeam: vi.fn(),
93+
onAssignTag: vi.fn(),
94+
onDeleteConversation: vi.fn(),
95+
unreadCount: 0,
96+
};
97+
98+
beforeEach(() => {
99+
vi.clearAllMocks();
100+
vi.mocked(chatService.getConversation).mockResolvedValue({ data: makeConversation() } as never);
101+
});
102+
103+
const openPipelineAndSelectStage = async (
104+
user: ReturnType<typeof userEvent.setup>,
105+
pipelineName: string,
106+
stageName: string,
107+
) => {
108+
// Open main dropdown
109+
const menuTrigger = document.querySelector<HTMLElement>('[data-slot="dropdown-menu-trigger"]')!;
110+
await user.click(menuTrigger);
111+
112+
// Find and focus the pipeline.addTo sub-trigger, then open it with ArrowRight
113+
const addToTrigger = await screen.findByText('pipeline.addTo');
114+
const addToSubTrigger = (addToTrigger.closest('[data-slot="dropdown-menu-sub-trigger"]') ?? addToTrigger) as HTMLElement;
115+
addToSubTrigger.focus();
116+
await user.keyboard('{ArrowRight}');
117+
118+
// Find and focus the specific pipeline sub-trigger, then open with ArrowRight
119+
await waitFor(() => screen.getByText(pipelineName), { timeout: 2000 });
120+
const pipelineEl = screen.getByText(pipelineName);
121+
const pipelineSubTrigger = (pipelineEl.closest('[data-slot="dropdown-menu-sub-trigger"]') ?? pipelineEl) as HTMLElement;
122+
pipelineSubTrigger.focus();
123+
await user.keyboard('{ArrowRight}');
124+
125+
// Find and click the stage
126+
await waitFor(() => screen.getByText(stageName), { timeout: 2000 });
127+
await user.click(screen.getByText(stageName));
128+
};
129+
130+
describe('ChatHeader pipeline', () => {
131+
it('loads pipelines on mount and calls getPipelinesByConversation when menu opens', async () => {
132+
const pipeline = makePipeline('p1', [{ id: 'stage-1', name: 'Lead' }]);
133+
vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pipeline] } as never);
134+
vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([]);
135+
136+
render(<ChatHeader {...defaultProps} />);
137+
138+
await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalledWith({ is_active: true }));
139+
140+
const user = userEvent.setup();
141+
const menuTrigger = document.querySelector<HTMLElement>('[data-slot="dropdown-menu-trigger"]')!;
142+
await user.click(menuTrigger);
143+
144+
await waitFor(() =>
145+
expect(pipelinesService.getPipelinesByConversation).toHaveBeenCalledWith('42'),
146+
);
147+
});
148+
149+
it('adds conversation to pipeline using conversation.id not item.id (H1)', async () => {
150+
const pipeline = makePipeline('p1', [{ id: 'stage-1', name: 'Lead' }]);
151+
vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pipeline] } as never);
152+
vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([]);
153+
vi.mocked(pipelinesService.addItemToPipeline).mockResolvedValue({} as never);
154+
155+
render(<ChatHeader {...defaultProps} />);
156+
await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled());
157+
158+
const user = userEvent.setup();
159+
await openPipelineAndSelectStage(user, 'Pipeline p1', 'Lead');
160+
161+
await waitFor(() => {
162+
expect(pipelinesService.addItemToPipeline).toHaveBeenCalledWith('p1', {
163+
item_id: '42',
164+
type: 'conversation',
165+
pipeline_stage_id: 'stage-1',
166+
});
167+
});
168+
});
169+
170+
it('calls moveItem with item.id when moving within same pipeline (M1)', async () => {
171+
const existingItem = {
172+
id: 'item-99',
173+
item_id: '42',
174+
stage_id: 'stage-1',
175+
pipeline_id: 'p1',
176+
type: 'conversation',
177+
is_lead: false,
178+
created_at: '',
179+
updated_at: '',
180+
};
181+
const pipeline = makePipeline(
182+
'p1',
183+
[
184+
{ id: 'stage-1', name: 'Lead' },
185+
{ id: 'stage-2', name: 'Qualified' },
186+
],
187+
[existingItem],
188+
);
189+
190+
vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pipeline] } as never);
191+
vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([pipeline]);
192+
vi.mocked(pipelinesService.moveItem).mockResolvedValue({ success: true, message: '' });
193+
194+
render(<ChatHeader {...defaultProps} />);
195+
await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled());
196+
197+
const user = userEvent.setup();
198+
await openPipelineAndSelectStage(user, 'Pipeline p1', 'Qualified');
199+
200+
await waitFor(() => {
201+
expect(pipelinesService.moveItem).toHaveBeenCalledWith({
202+
pipeline_id: 'p1',
203+
item_id: 'item-99',
204+
from_stage_id: 'stage-1',
205+
to_stage_id: 'stage-2',
206+
});
207+
expect(pipelinesService.addItemToPipeline).not.toHaveBeenCalled();
208+
});
209+
});
210+
211+
it('removes from old pipeline before adding to new pipeline (C1)', async () => {
212+
const existingItem = {
213+
id: 'item-old',
214+
item_id: '42',
215+
stage_id: 'stage-A',
216+
pipeline_id: 'p-old',
217+
type: 'conversation',
218+
is_lead: false,
219+
created_at: '',
220+
updated_at: '',
221+
};
222+
const oldPipeline = makePipeline('p-old', [{ id: 'stage-A', name: 'StageOld' }], [existingItem]);
223+
const newPipeline = makePipeline('p-new', [{ id: 'stage-B', name: 'StageNew' }]);
224+
225+
vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [oldPipeline, newPipeline] } as never);
226+
vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([oldPipeline]);
227+
vi.mocked(pipelinesService.removeItemFromPipeline).mockResolvedValue({ success: true, message: '' });
228+
vi.mocked(pipelinesService.addItemToPipeline).mockResolvedValue({} as never);
229+
230+
render(<ChatHeader {...defaultProps} />);
231+
await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled());
232+
233+
const user = userEvent.setup();
234+
await openPipelineAndSelectStage(user, 'Pipeline p-new', 'StageNew');
235+
236+
await waitFor(() => {
237+
expect(pipelinesService.removeItemFromPipeline).toHaveBeenCalledWith('p-old', 'item-old');
238+
expect(pipelinesService.addItemToPipeline).toHaveBeenCalledWith('p-new', {
239+
item_id: '42',
240+
type: 'conversation',
241+
pipeline_stage_id: 'stage-B',
242+
});
243+
});
244+
});
245+
246+
it('shows pipeline.moveError toast when move fails (M2 - distinct from addError)', async () => {
247+
const existingItem = {
248+
id: 'item-99',
249+
item_id: '42',
250+
stage_id: 'stage-1',
251+
pipeline_id: 'p1',
252+
type: 'conversation',
253+
is_lead: false,
254+
created_at: '',
255+
updated_at: '',
256+
};
257+
const pipeline = makePipeline(
258+
'p1',
259+
[
260+
{ id: 'stage-1', name: 'Lead' },
261+
{ id: 'stage-2', name: 'Qualified' },
262+
],
263+
[existingItem],
264+
);
265+
266+
vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pipeline] } as never);
267+
vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([pipeline]);
268+
vi.mocked(pipelinesService.moveItem).mockRejectedValue(new Error('Server error'));
269+
270+
render(<ChatHeader {...defaultProps} />);
271+
await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled());
272+
273+
const user = userEvent.setup();
274+
await openPipelineAndSelectStage(user, 'Pipeline p1', 'Qualified');
275+
276+
await waitFor(() => {
277+
expect(toast.error).toHaveBeenCalledWith('pipeline.moveError');
278+
expect(toast.error).not.toHaveBeenCalledWith('pipeline.addError');
279+
});
280+
});
281+
});

0 commit comments

Comments
 (0)