From 540bcd44c16681ced510a671e7cf9a74728ae28a Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 12 Sep 2026 06:33:08 +0000 Subject: [PATCH 1/2] fix: let users retry loading tasks --- apps/web/src/app/(authenticated)/tasks/Tasks.tsx | 2 +- .../components/tasks/TaskCardError.client.test.tsx | 14 ++++++++++++++ apps/web/src/components/tasks/TaskCardError.tsx | 6 +++++- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/tasks/TaskCardError.client.test.tsx diff --git a/apps/web/src/app/(authenticated)/tasks/Tasks.tsx b/apps/web/src/app/(authenticated)/tasks/Tasks.tsx index 6451725111..98d413418e 100644 --- a/apps/web/src/app/(authenticated)/tasks/Tasks.tsx +++ b/apps/web/src/app/(authenticated)/tasks/Tasks.tsx @@ -650,7 +650,7 @@ export const Tasks = () => { ) : isError ? (
- + void infiniteTasks.refetch()} />
) : tasks.length === 0 ? (
diff --git a/apps/web/src/components/tasks/TaskCardError.client.test.tsx b/apps/web/src/components/tasks/TaskCardError.client.test.tsx new file mode 100644 index 0000000000..ed6a5d2a19 --- /dev/null +++ b/apps/web/src/components/tasks/TaskCardError.client.test.tsx @@ -0,0 +1,14 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { TaskCardError } from './TaskCardError'; + +describe('TaskCardError', () => { + it('lets the user retry loading tasks', () => { + const onRetry = vi.fn(); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + + expect(onRetry).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/components/tasks/TaskCardError.tsx b/apps/web/src/components/tasks/TaskCardError.tsx index 6f55666d8d..7d4b47afd1 100644 --- a/apps/web/src/components/tasks/TaskCardError.tsx +++ b/apps/web/src/components/tasks/TaskCardError.tsx @@ -4,9 +4,10 @@ import { EmptyMedia, EmptyDescription, CircleX, + Button, } from '@/components/system'; -export function TaskCardError() { +export function TaskCardError({ onRetry }: { onRetry: () => void }) { return ( @@ -16,6 +17,9 @@ export function TaskCardError() { Failed to load tasks. + ); From 6b46c20627b8fbda47a70406a44da9fa718e5407 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:35:32 +0000 Subject: [PATCH 2/2] test: cover the tasks list failure, retry and recovery wiring --- .../tasks/Tasks.client.test.tsx | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 apps/web/src/app/(authenticated)/tasks/Tasks.client.test.tsx diff --git a/apps/web/src/app/(authenticated)/tasks/Tasks.client.test.tsx b/apps/web/src/app/(authenticated)/tasks/Tasks.client.test.tsx new file mode 100644 index 0000000000..faa2472368 --- /dev/null +++ b/apps/web/src/app/(authenticated)/tasks/Tasks.client.test.tsx @@ -0,0 +1,141 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +type InfiniteTasksState = { + data?: { pages: { tasks: { id: string }[]; nextCursor?: string }[] }; + isPending: boolean; + isError: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; +}; + +const mocks = vi.hoisted(() => ({ + refetch: vi.fn(), + fetchNextPage: vi.fn(), + deleteMutate: vi.fn(), + replace: vi.fn(), + push: vi.fn(), + state: {} as InfiniteTasksState, +})); + +const loadedPage = { + pages: [{ tasks: [{ id: 'task-1' }, { id: 'task-2' }] }], +}; + +function setState(next: Partial) { + Object.assign(mocks.state, next); +} + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: mocks.replace, push: mocks.push }), + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +vi.mock('@/hooks/useUser', () => ({ + useAuthorizedUser: () => ({ userId: 'user-1', isAdmin: false }), +})); + +vi.mock('@/hooks/tasks', () => ({ + useInfiniteTasks: () => ({ + ...mocks.state, + refetch: mocks.refetch, + fetchNextPage: mocks.fetchNextPage, + }), + useDeleteTasks: () => ({ mutate: mocks.deleteMutate, isPending: false }), + useTaskFilterState: () => ({ + hasSpecificUserFilter: false, + hasNonDefaultFilters: false, + }), +})); + +vi.mock('@/components/tasks', async () => { + const { TaskCardError } = await import('@/components/tasks/TaskCardError'); + + return { + TaskCardError, + TaskFilters: () =>
, + TaskCard: ({ task }: { task: { id: string } }) => ( +
{task.id}
+ ), + TaskBoard: ({ tasks }: { tasks: { id: string }[] }) => ( +
{tasks.length}
+ ), + TaskCardSkeleton: () =>
, + TaskBoardSkeleton: () =>
, + }; +}); + +import { Tasks } from './Tasks'; + +describe('Tasks', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + setState({ + data: undefined, + isPending: true, + isError: false, + hasNextPage: false, + isFetchingNextPage: false, + }); + }); + + it('shows the loading skeleton while the initial query is pending', () => { + render(); + + expect(screen.getByTestId('task-card-skeleton')).toBeInTheDocument(); + expect(screen.queryByText('Failed to load tasks.')).not.toBeInTheDocument(); + }); + + it('shows the retry action once the initial load has failed', async () => { + setState({ isPending: false, isError: true, data: undefined }); + + render(); + + expect( + await screen.findByText('Failed to load tasks.'), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + expect(screen.queryAllByTestId('task-card')).toHaveLength(0); + }); + + it('refetches the tasks query and restores the list when the user retries', async () => { + setState({ isPending: false, isError: true, data: undefined }); + + const { rerender } = render(); + const retry = await screen.findByRole('button', { name: 'Retry' }); + + // A successful refetch flips the query into its loaded state, exactly as + // React Query would after the request succeeds. + mocks.refetch.mockImplementation(() => { + setState({ isError: false, data: loadedPage }); + return Promise.resolve(); + }); + + fireEvent.click(retry); + + expect(mocks.refetch).toHaveBeenCalledOnce(); + + rerender(); + + await waitFor(() => + expect(screen.getAllByTestId('task-card')).toHaveLength(2), + ); + expect(screen.queryByText('Failed to load tasks.')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull(); + }); + + it('keeps already loaded tasks visible when a later refresh fails', async () => { + setState({ isPending: false, isError: true, data: loadedPage }); + + render(); + + await waitFor(() => + expect(screen.getAllByTestId('task-card')).toHaveLength(2), + ); + expect(screen.queryByText('Failed to load tasks.')).not.toBeInTheDocument(); + }); +});