taskGroupsPage.test.tsx10.7 KBView on GitHub
/**
 * TaskGroupsPage — the `/tasks/groups` config surface.
 *
 * Pins the four properties the surface exists for: every group is rendered in its stored
 * `position` order (that order is the section order on the list and the column order on the
 * board), the virtual Misc lane is inert, reordering writes through `taskGroups.reorderGroups`,
 * and create/delete round-trip to their mutations — delete only after the confirm, since it
 * re-files tasks into Misc rather than deleting them.
 */
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';

// ── Mocks ─────────────────────────────────────────────────────────────────────

const mockMutations = {
  createGroup: jest.fn(),
  updateGroup: jest.fn(),
  deleteGroup: jest.fn(),
  reorderGroups: jest.fn(),
};

const mockGroupsData: { groups: unknown[] } = { groups: [] };
const mockSetQueriesData = jest.fn();
const mockInvalidateQueries = jest.fn();

jest.mock('@tanstack/react-query', () => ({
  useQuery: (opts: { __kind?: string }) => {
    if (opts.__kind === 'groups') return { data: mockGroupsData, isLoading: false };
    return { data: undefined, isLoading: false };
  },
  useMutation: (opts: { __name?: string; onSuccess?: (res: unknown) => void }) => ({
    mutate: (vars: unknown, extra?: { onSettled?: () => void }) => {
      if (opts.__name) mockMutations[opts.__name as keyof typeof mockMutations](vars);
      opts.onSuccess?.({ success: true, tasksMovedToMisc: 0 });
      extra?.onSettled?.();
    },
    isPending: false,
  }),
  useQueryClient: () => ({
    invalidateQueries: mockInvalidateQueries,
    setQueriesData: mockSetQueriesData,
  }),
}));

const mutation = (name: string) => ({
  mutationOptions: (opts: Record<string, unknown> = {}) => ({ __name: name, ...opts }),
});

jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    taskGroups: {
      listGroups: {
        queryOptions: () => ({ __kind: 'groups' }),
        queryKey: () => ['taskGroups', 'listGroups'],
      },
      createGroup: mutation('createGroup'),
      updateGroup: mutation('updateGroup'),
      deleteGroup: mutation('deleteGroup'),
      reorderGroups: mutation('reorderGroups'),
    },
    userTasks: { listUserTasks: { queryKey: () => ['userTasks'] } },
  }),
}));

jest.mock('sonner', () => ({ toast: { success: jest.fn(), error: jest.fn() } }));

// The colour picker is a motion-driven radial widget; its physics have nothing to do with this
// surface's behaviour, so it stands in as a plain button.
jest.mock('@/components/ui/SexyColourPicker', () => ({
  // `dark[5]` is the blue the page defaults a new group to.
  CEDAR_COLORS: {
    center: '#ffffff',
    light: [],
    dark: ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6'],
  },
  ColorPickerPopover: ({ onColorSelect }: { onColorSelect: (c: string) => void }) => (
    <button type="button" aria-label="Choose colour" onClick={() => onColorSelect('#ef4444')} />
  ),
}));

import { TaskGroupsPage } from '@/modules/userTasks/components/TaskGroupsPage';

// ── Fixtures ──────────────────────────────────────────────────────────────────

function group(id: string, name: string, position: number, extra: Record<string, unknown> = {}) {
  return {
    id,
    userId: 'user-1',
    name,
    color: '#3b82f6',
    icon: 'Trophy',
    position,
    routingCriteria: `Anything about ${name}`,
    overduePolicy: null,
    agentVisible: true,
    createdAt: null,
    updatedAt: null,
    isMisc: false,
    openTaskCount: 2,
    topTasks: [],
    ...extra,
  };
}

const MISC = {
  id: null,
  userId: 'user-1',
  name: 'Misc',
  color: null,
  icon: null,
  position: Number.MAX_SAFE_INTEGER,
  routingCriteria: null,
  overduePolicy: null,
  agentVisible: true,
  createdAt: null,
  updatedAt: null,
  isMisc: true,
  openTaskCount: 7,
  topTasks: [],
};

beforeEach(() => {
  jest.clearAllMocks();
  // Deliberately out of order in the array — the page sorts by `position`.
  mockGroupsData.groups = [
    group('g-b', 'Follow-ups', 1),
    group('g-c', 'CRM updates', 2),
    group('g-a', 'Responses needed', 0),
    MISC,
  ];
});

const groupNames = () =>
  screen.getAllByLabelText('Group name').map((el) => (el as HTMLInputElement).value);

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('TaskGroupsPage — rendering', () => {
  it('renders every group in position order, with its open task count', () => {
    render(<TaskGroupsPage />);
    expect(groupNames()).toEqual(['Responses needed', 'Follow-ups', 'CRM updates']);
    expect(screen.getAllByText('2 open')).toHaveLength(3);
  });

  it('exposes every configurable field on a group', () => {
    render(<TaskGroupsPage />);
    expect(screen.getByDisplayValue('Anything about Follow-ups')).toBeInTheDocument();
    expect(screen.getByLabelText('Agent visibility for Follow-ups')).toBeInTheDocument();
    expect(screen.getByLabelText('Overdue policy for Follow-ups')).toBeInTheDocument();
    expect(screen.getByLabelText('Grace window for Follow-ups')).toBeInTheDocument();
    expect(screen.getByLabelText('Agent may reschedule Follow-ups')).toBeInTheDocument();
    expect(screen.getAllByLabelText('Choose icon').length).toBeGreaterThan(0);
  });

  it('renders the virtual Misc lane read-only — no name field, no reorder, no delete', () => {
    render(<TaskGroupsPage />);
    expect(screen.getByText('Misc')).toBeInTheDocument();
    expect(screen.getByText('7 open')).toBeInTheDocument();
    // Only the three real groups get an editable name.
    expect(groupNames()).toHaveLength(3);
    expect(screen.queryByLabelText('Delete Misc')).not.toBeInTheDocument();
    expect(screen.queryByLabelText('Move Misc up')).not.toBeInTheDocument();
    expect(screen.queryByLabelText('Move Misc down')).not.toBeInTheDocument();
    expect(screen.queryByLabelText('Drag Misc')).not.toBeInTheDocument();
  });
});

describe('TaskGroupsPage — reorder', () => {
  it('moving a group down calls reorderGroups with the new id order', () => {
    render(<TaskGroupsPage />);
    fireEvent.click(screen.getByLabelText('Move Responses needed down'));
    expect(mockMutations.reorderGroups).toHaveBeenCalledWith({
      orderedGroupIds: ['g-b', 'g-a', 'g-c'],
    });
  });

  it('moving a group up calls reorderGroups with the new id order', () => {
    render(<TaskGroupsPage />);
    fireEvent.click(screen.getByLabelText('Move CRM updates up'));
    expect(mockMutations.reorderGroups).toHaveBeenCalledWith({
      orderedGroupIds: ['g-a', 'g-c', 'g-b'],
    });
  });

  it('writes the new order into the groups cache before the round trip', () => {
    render(<TaskGroupsPage />);
    fireEvent.click(screen.getByLabelText('Move Responses needed down'));
    expect(mockSetQueriesData).toHaveBeenCalled();
  });

  it('Misc is never part of the reordered id list', () => {
    render(<TaskGroupsPage />);
    fireEvent.click(screen.getByLabelText('Move Responses needed down'));
    const { orderedGroupIds } = mockMutations.reorderGroups.mock.calls[0][0];
    expect(orderedGroupIds).not.toContain(null);
    expect(orderedGroupIds).toHaveLength(3);
  });

  it('the first group cannot move up and the last cannot move down', () => {
    render(<TaskGroupsPage />);
    expect(screen.getByLabelText('Move Responses needed up')).toBeDisabled();
    expect(screen.getByLabelText('Move CRM updates down')).toBeDisabled();
  });
});

describe('TaskGroupsPage — create and delete', () => {
  it('creates a group with name, colour and icon', () => {
    render(<TaskGroupsPage />);
    fireEvent.click(screen.getByText('New group'));
    fireEvent.change(screen.getByLabelText('New group name'), { target: { value: 'Recruiting' } });
    fireEvent.change(screen.getByLabelText('New group auto-file rule'), {
      target: { value: 'Anything about hiring' },
    });
    fireEvent.click(screen.getByText('Create'));

    expect(mockMutations.createGroup).toHaveBeenCalledWith({
      name: 'Recruiting',
      color: '#3b82f6',
      icon: 'ListTodo',
      routingCriteria: 'Anything about hiring',
    });
  });

  it('does not create a group with a blank name', () => {
    render(<TaskGroupsPage />);
    fireEvent.click(screen.getByText('New group'));
    fireEvent.change(screen.getByLabelText('New group name'), { target: { value: '   ' } });
    fireEvent.click(screen.getByText('Create'));
    expect(mockMutations.createGroup).not.toHaveBeenCalled();
  });

  it('deletes only after the confirm, and says tasks are re-filed rather than deleted', () => {
    render(<TaskGroupsPage />);
    fireEvent.click(screen.getByLabelText('Delete Follow-ups'));
    expect(mockMutations.deleteGroup).not.toHaveBeenCalled();

    expect(screen.getByText(/No tasks are deleted/)).toBeInTheDocument();
    expect(screen.getByText(/re-filed into Misc/)).toBeInTheDocument();

    fireEvent.click(screen.getByText('Delete group'));
    expect(mockMutations.deleteGroup).toHaveBeenCalledWith({ groupId: 'g-b' });
  });

  it('cancelling the confirm leaves the group alone', () => {
    render(<TaskGroupsPage />);
    fireEvent.click(screen.getByLabelText('Delete Follow-ups'));
    fireEvent.click(screen.getByText('Cancel'));
    expect(mockMutations.deleteGroup).not.toHaveBeenCalled();
  });
});

describe('TaskGroupsPage — field edits', () => {
  it('renaming a group on blur patches only that group', () => {
    render(<TaskGroupsPage />);
    const input = screen.getAllByLabelText('Group name')[1];
    fireEvent.change(input, { target: { value: 'Follow ups (renamed)' } });
    fireEvent.blur(input);
    expect(mockMutations.updateGroup).toHaveBeenCalledWith({
      groupId: 'g-b',
      name: 'Follow ups (renamed)',
    });
  });

  it('toggling agent visibility patches agentVisible', () => {
    render(<TaskGroupsPage />);
    fireEvent.click(screen.getByLabelText('Agent visibility for Follow-ups'));
    expect(mockMutations.updateGroup).toHaveBeenCalledWith({
      groupId: 'g-b',
      agentVisible: false,
    });
  });

  it('editing the grace window patches the whole overdue policy', () => {
    render(<TaskGroupsPage />);
    const input = screen.getByLabelText('Grace window for Follow-ups');
    fireEvent.change(input, { target: { value: '10' } });
    fireEvent.blur(input);
    expect(mockMutations.updateGroup).toHaveBeenCalledWith({
      groupId: 'g-b',
      overduePolicy: { mode: 'nag', afterDays: 10, agentMayReschedule: true },
    });
  });
});