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();
  });
});