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