taskTicketView.test.tsx14.3 KBView on GitHub /**
* TaskTicketView — the task-as-ticket surface.
*
* Pins the two things the redesign exists to guarantee and one it must not break:
*
* - the task's own content is ON SCREEN. The panel this replaces fetched `notes`, the lane,
* the output kind and the created-by and rendered none of them, spending the whole centre
* column on one small card. A regression here looks like a working page, just an empty one.
*
* - Create draft appears exactly when running the task is the action. A task that already produced
* something is opened, not re-run, and a teammate's task is theirs to run — so the button
* showing in either case invites an action that will not do what the user expects.
*
* - the conversation is reachable. The ticket replaced a landing that WAS the conversation, so
* losing the link would strand the user on the task with no way back to the deal.
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { TooltipProvider } from '@/components/ui/tooltip';
const mockTask: { task: Record<string, unknown> | null } = { task: null };
const mockExecuteTaskNow = jest.fn();
jest.mock('@/providers/query-provider', () => ({
useTRPC: () => ({
userTasks: {
getTaskById: { queryOptions: () => ({ queryKey: ['task'] }), queryKey: () => ['task'] },
listUserTasks: {
queryOptions: () => ({ queryKey: ['tasks'] }),
queryKey: () => ['tasks'],
},
// Notes have no optimistic action of their own — they save through updateTask.
updateTask: { mutationOptions: () => ({}) },
},
crm: {
getConversation: {
queryOptions: () => ({ queryKey: ['conv'] }),
queryKey: () => ['conv'],
},
},
}),
}));
// One query hook serves both calls the view makes; keyed off the queryKey the mock above returns.
jest.mock('@tanstack/react-query', () => ({
useQuery: (opts: { queryKey?: unknown[] }) => {
const key=[redacted];
// The header's prev/next reads the open-task queue.
if (key === 'tasks') return { data: { tasks: [{ id: 'task-1' }] } };
if (key === 'conv') {
return {
data: {
conversation: { id: 'conv-1', name: 'Daniel @ edexia.ai' },
company: { name: 'Edexia', logoUrl: null },
},
};
}
return { data: mockTask.task ? mockTask : null, isLoading: false, isError: !mockTask.task };
},
useQueryClient: () => ({ invalidateQueries: jest.fn() }),
useMutation: (opts: { onSuccess?: () => void }) => ({
mutate: (vars: unknown) => {
mockSaveNotes(vars);
opts.onSuccess?.();
},
}),
}));
const mockDeleteTask = jest.fn();
const mockSaveNotes = jest.fn();
const mockUpdateDescription = jest.fn();
jest.mock('@/modules/userTasks/hooks/use-optimistic-task-actions', () => ({
useOptimisticTaskActions: () => ({
sendSlackDraftFromTask: jest.fn(),
optimisticDeleteTask: mockDeleteTask,
optimisticUpdateTaskDescription: mockUpdateDescription,
}),
}));
// Whether a run is in flight is the slice's business and has its own tests; here it only
// decides which label the Create draft button shows.
jest.mock('@/modules/userTasks/hooks/use-task-is-executing', () => ({
useTaskIsExecuting: () => false,
}));
jest.mock('@/modules/userTasks/hooks/use-execute-task-now', () => ({
useExecuteTaskNow: () => mockExecuteTaskNow,
}));
jest.mock('@/modules/store', () => ({
useCedarStore: (selector: (s: unknown) => unknown) =>
selector({ clearArtifact: jest.fn(), setSelectedArtifact: jest.fn(), openThread: jest.fn() }),
}));
jest.mock('nuqs', () => ({ useQueryState: () => [null, jest.fn()] }));
jest.mock('@/modules/agentCanvas/utils/open-conversation', () => ({
openConversationFromAgenda: jest.fn(),
}));
jest.mock('sonner', () => ({ toast: { error: jest.fn(), success: jest.fn() } }));
// The thread row and its hydration have their own tests and pull the whole mail store; here the
// question is only whether the SECTION is rendered for a task that names a thread.
jest.mock('@/modules/threads/thread/components/thread-data-sync', () => ({
ThreadDataSync: ({ threadId }: { threadId: string }) => (
<div data-testid="thread-data-sync">{threadId}</div>
),
}));
jest.mock('@/modules/threads/threadList/threadItem/components/thread', () => ({
Thread: ({ message }: { message: { id: string } }) => (
<div data-testid="thread-row">{message.id}</div>
),
}));
// The properties rail has its own suite (taskTicketProperties.test.tsx) and its own mutation
// surface. Stubbed to the values this file asserts on, so these tests stay about the content
// column without re-mocking four procedures.
jest.mock('@/modules/userTasks/components/TaskTicketProperties', () => ({
TaskTicketProperties: ({
task,
}: {
task: { taskGroup: { name?: string | null } | null; taskCreatedBy: string | null };
}) => (
<aside>
<span>{task.taskGroup?.name ?? 'Misc'}</span>
<span>{task.taskCreatedBy === 'agent' ? 'Cedar' : 'You'}</span>
</aside>
),
}));
// The shared conversation badge is a split button that resolves the deal, reads the store and
// can re-link it — all with their own tests. Here it only matters that the ticket renders THAT
// badge, for THIS task's conversation.
jest.mock('@/modules/threads/thread/components/ConversationBadge', () => ({
ConversationBadge: ({ conversationId }: { conversationId?: string | null }) => (
<span data-testid="conversation-badge">{conversationId}</span>
),
}));
// The badge has its own tests; here it only decides whether the task reads as a teammate's.
const mockForeignOwner: { value: { id: string; name: string } | null } = { value: null };
jest.mock('@/modules/userTasks/components/TaskOwnerBadge', () => ({
TaskOwnerBadge: () => null,
useForeignTaskOwner: () => mockForeignOwner.value,
}));
import { TaskTicketView } from '@/modules/userTasks/components/TaskTicketView';
/**
* The breadcrumb links to /tasks and to the lane, and the header's actions carry tooltips —
* so the view needs both a router and a tooltip provider around it, exactly as the app supplies.
*/
function renderTicket() {
return render(
<MemoryRouter>
<TooltipProvider>
<TaskTicketView taskId="task-1" />
</TooltipProvider>
</MemoryRouter>,
);
}
/** A reminder: the no-output task the ticket exists for. */
function reminderTask(overrides: Record<string, unknown> = {}) {
return {
id: 'task-1',
description: 'Check in before the renewal',
notes: 'Priaav asked to revisit once the AEs are onboarded.',
status: 'todo',
dueDate: '2030-05-07T09:00:00Z',
conversationId: 'conv-1',
sourceThreadId: null,
taskActionData: null,
taskOutput: null,
taskCreatedBy: 'agent',
agentExecutionEnabled: false,
taskGroup: { id: 'grp-1', name: 'Followups' },
user: { id: 'me', name: 'Jesse', email: null, image: null },
...overrides,
};
}
beforeEach(() => {
jest.clearAllMocks();
mockForeignOwner.value = null;
mockTask.task = reminderTask();
});
describe('TaskTicketView', () => {
it('renders the title, the conversation and the notes', () => {
renderTicket();
expect(screen.getByLabelText('Task description')).toHaveTextContent(
'Check in before the renewal',
);
expect(
screen.getByText('Priaav asked to revisit once the AEs are onboarded.'),
).toBeInTheDocument();
// The deal sits directly under the title as the SAME badge the open-thread header uses —
// the ticket replaced a landing that WAS the deal, so losing the link would strand the user.
expect(screen.getByTestId('conversation-badge')).toHaveTextContent('conv-1');
});
it('hands the rail the metadata the old panel fetched and threw away', () => {
renderTicket();
// Scoped to the rail: the lane also appears in the breadcrumb now, so an unscoped query
// matches twice and says nothing about which surface received it.
const rail = screen.getByRole('complementary');
expect(rail).toHaveTextContent('Followups');
expect(rail).toHaveTextContent('Cedar');
});
it('puts Tasks › lane in the breadcrumb, and not the task title', () => {
renderTicket();
// The bar leads with the surface you came from and the lane, NOT the deal. It stops there:
// the task's own title is the h1 immediately below, so repeating it is noise.
expect(screen.getByRole('link', { name: 'Tasks' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Followups' })).toBeInTheDocument();
// Once only — as the heading, not also in the bar.
expect(screen.getAllByText('Check in before the renewal')).toHaveLength(1);
});
it('names the Misc lane rather than leaving it blank', () => {
// A null group IS a lane — the virtual Misc one — not missing data.
mockTask.task = reminderTask({ taskGroup: null });
renderTicket();
expect(screen.getByRole('complementary')).toHaveTextContent('Misc');
expect(screen.getByRole('link', { name: 'Misc' })).toBeInTheDocument();
});
it('offers Create draft on a no-output task of your own', () => {
renderTicket();
expect(screen.getByRole('button', { name: /create draft/i })).toBeInTheDocument();
});
it('withholds Create draft once the task has produced something', () => {
// A task with an output is opened, not re-run.
mockTask.task = reminderTask({
taskActionData: { channel: 'email', threadId: 'thread-1', draftId: 'draft-1' },
});
renderTicket();
expect(screen.queryByRole('button', { name: /create draft/i })).not.toBeInTheDocument();
});
it("withholds Create draft on a teammate's task", () => {
mockForeignOwner.value = { id: 'teammate', name: 'Isabelle' };
renderTicket();
expect(screen.queryByRole('button', { name: /create draft/i })).not.toBeInTheDocument();
expect(screen.getByText(/only they can run it/i)).toBeInTheDocument();
});
it('renders the thread the task is about, when it names one', () => {
// source_thread_id is the whole point of phase 1 — a reminder that knows which mail it
// concerns should show that mail rather than describing it.
mockTask.task = reminderTask({ sourceThreadId: 'thread-abc' });
renderTicket();
expect(screen.getByTestId('thread-row')).toHaveTextContent('thread-abc');
// Hydration is mounted alongside it, or the row renders empty.
expect(screen.getByTestId('thread-data-sync')).toHaveTextContent('thread-abc');
});
it('renders no thread section at all when the task names no thread', () => {
// Not an empty box: that would imply mail which does not exist.
renderTicket();
expect(screen.queryByTestId('thread-row')).not.toBeInTheDocument();
expect(screen.queryByTestId('thread-data-sync')).not.toBeInTheDocument();
});
it('discards the task from the action row', () => {
// Destructive and it closes the ticket, so it is pinned rather than left to a click-through.
renderTicket();
fireEvent.click(screen.getByRole('button', { name: /delete task/i }));
expect(mockDeleteTask).toHaveBeenCalledWith('task-1');
});
it("hides delete on a teammate's task", () => {
mockForeignOwner.value = { id: 'teammate', name: 'Isabelle' };
renderTicket();
expect(screen.queryByRole('button', { name: /delete task/i })).not.toBeInTheDocument();
});
/** EditableText is static text until you click it, so every edit starts with the click. */
function startEditing(label: string): HTMLElement {
fireEvent.click(screen.getByLabelText(label));
return screen.getByLabelText(label);
}
it('saves an edited description on blur, and abandons it on Escape', () => {
// Auto-save means there is no Save button to prove the edit landed — so both halves are
// pinned here: a blur that does not persist loses the user's typing silently, and an Escape
// that persists writes something they explicitly backed out of.
renderTicket();
const field = startEditing('Task description');
fireEvent.change(field, { target: { value: 'Check in after the renewal' } });
fireEvent.blur(field);
expect(mockUpdateDescription).toHaveBeenCalledWith(
'task-1',
'Check in after the renewal',
'conv-1',
);
mockUpdateDescription.mockClear();
const again = startEditing('Task description');
fireEvent.change(again, { target: { value: 'abandoned' } });
fireEvent.keyDown(again, { key=[redacted] });
expect(mockUpdateDescription).not.toHaveBeenCalled();
});
it('does not save a description that is unchanged or empty', () => {
renderTicket();
const field = startEditing('Task description');
fireEvent.blur(field);
const second = startEditing('Task description');
fireEvent.change(second, { target: { value: ' ' } });
fireEvent.blur(second);
expect(mockUpdateDescription).not.toHaveBeenCalled();
});
it('saves edited notes the same way', () => {
// The notes are prose the agent wrote; correcting them should not mean opening something
// else, and they save through updateTask rather than an optimistic action of their own.
renderTicket();
const notes = startEditing('Task notes');
fireEvent.change(notes, { target: { value: 'Cameron installs it Wednesday.' } });
fireEvent.blur(notes);
expect(mockSaveNotes).toHaveBeenCalledWith({
taskId: 'task-1',
notes: 'Cameron installs it Wednesday.',
});
});
it('offers the back affordance every other full-screen view has', () => {
// Two elements carry it, exactly as thread-display does: the full-height left gutter and
// the small header button it collapses to on a narrow window. A container query shows one
// or the other, never both, so the duplicate accessible name is width-exclusive.
//
// It CLOSES the ticket rather than navigating, so it returns you to whatever you opened the
// task from — the board, the list, a conversation — instead of a fixed destination.
renderTicket();
expect(screen.getAllByRole('button', { name: 'Back' })).toHaveLength(2);
});
it('offers a way out when the task cannot be loaded', () => {
// The ticket is the whole centre column; without this the state is a dead end for anyone
// who does not know Escape closes it.
mockTask.task = null;
renderTicket();
expect(screen.getByText('Could not load this task.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
});
});