diff --git a/e2e/clients.spec.ts b/e2e/clients.spec.ts index f225c188..6d8e3d05 100644 --- a/e2e/clients.spec.ts +++ b/e2e/clients.spec.ts @@ -246,7 +246,7 @@ test('test that sorting clients by name and status works', async ({ page, ctx }) test('test that sorting clients by project count works', async ({ page, ctx }) => { const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' }); - const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' }); + await createClientViaApi(ctx, { name: 'NoProjects Client' }); // Create projects for the first client await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id }); @@ -374,3 +374,119 @@ test.describe('Employee Clients Restrictions', () => { await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 }); }); }); + +// ────────────────────────────────────────────────── +// Pagination Tests +// ────────────────────────────────────────────────── + +test.describe('Clients Pagination', () => { + test.describe.configure({ timeout: 30000 }); + + test('test that client table paginates when there are more than 15 clients', async ({ + page, + ctx, + }) => { + // Create 17 clients with zero-padded names so alphabetical sort is predictable. + // Page size is 15 → page 1 shows indices 00–14, page 2 shows 15–16. + const seed = Math.floor(Math.random() * 100000); + const prefix = `PaginationClient ${seed} `; + await Promise.all( + Array.from({ length: 17 }, (_, i) => + createClientViaApi(ctx, { name: prefix + String(i).padStart(2, '0') }) + ) + ); + + await goToClientsOverview(page); + await clearClientTableState(page); + await page.reload(); + + // Default sort is name asc; first 15 clients (00–14) on page 1. + await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible(); + // Client 15 should be on page 2, not visible on page 1. + await expect(page.getByText(prefix + '15')).not.toBeVisible(); + + // Exactly 15 data rows mounted on page 1. + await expect(page.getByRole('row')).toHaveCount(15); + + // Navigation to page 2. + await page.getByRole('button', { name: 'Next Page' }).click(); + await expect(page.getByText(prefix + '15')).toBeVisible(); + await expect(page.getByText(prefix + '00')).not.toBeVisible(); + // Page 2 contains the remaining 2 clients. + await expect(page.getByRole('row')).toHaveCount(2); + + // Back to page 1 via Previous Page. + await page.getByRole('button', { name: 'Previous Page' }).click(); + await expect(page.getByText(prefix + '00')).toBeVisible(); + await expect(page.getByText(prefix + '15')).not.toBeVisible(); + + // First / Last page jumps. + await page.getByRole('button', { name: 'Last Page' }).click(); + await expect(page.getByText(prefix + '15')).toBeVisible(); + await page.getByRole('button', { name: 'First Page' }).click(); + await expect(page.getByText(prefix + '00')).toBeVisible(); + await expect(page.getByText(prefix + '15')).not.toBeVisible(); + + // Direct page-number button navigation + selected state. + await page.getByRole('button', { name: 'Page 2' }).click(); + await expect(page.getByText(prefix + '15')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute( + 'aria-current', + 'page' + ); + }); + + test('test that client pagination is not shown when there are 15 or fewer clients', async ({ + page, + ctx, + }) => { + await Promise.all( + Array.from({ length: 10 }, (_, i) => + createClientViaApi(ctx, { + name: `FewClient ${Math.floor(Math.random() * 100000)} ${i}`, + }) + ) + ); + + await goToClientsOverview(page); + await clearClientTableState(page); + await page.reload(); + + await expect(page.getByTestId('client_table')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0); + }); + + test('test that changing the sort resets client pagination to page 1', async ({ + page, + ctx, + }) => { + const seed = Math.floor(Math.random() * 100000); + const prefix = `SortPagClient ${seed} `; + await Promise.all( + Array.from({ length: 17 }, (_, i) => + createClientViaApi(ctx, { name: prefix + String(i).padStart(2, '0') }) + ) + ); + + await goToClientsOverview(page); + await clearClientTableState(page); + await page.reload(); + + await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 }); + + // Go to page 2. + await page.getByRole('button', { name: 'Next Page' }).click(); + await expect(page.getByText(prefix + '15')).toBeVisible(); + + // Sort by name descending. + const table = page.getByTestId('client_table'); + const nameHeader = table.getByText('Name').first(); + await nameHeader.click(); + + // Pagination reset to page 1; desc order → 16, 15 visible, 00 on page 2. + await expect(page.getByText(prefix + '16')).toBeVisible(); + await expect(page.getByText(prefix + '15')).toBeVisible(); + await expect(page.getByText(prefix + '00')).not.toBeVisible(); + }); +}); diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts index e34e4eb5..24ec45b2 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -1054,3 +1054,119 @@ test.describe('Employee Billable Rate Visibility', () => { await expect(projectRow).toContainText('200'); }); }); + +// ────────────────────────────────────────────────── +// Pagination Tests +// ────────────────────────────────────────────────── + +test.describe('Projects Pagination', () => { + test.describe.configure({ timeout: 30000 }); + + test('test that project table paginates when there are more than 15 projects', async ({ + page, + ctx, + }) => { + // Create 17 projects with zero-padded names so alphabetical sort is predictable. + // Page size is 15 → page 1 shows indices 00–14, page 2 shows 15–16. + const seed = Math.floor(Math.random() * 100000); + const prefix = `PaginationProj ${seed} `; + await Promise.all( + Array.from({ length: 17 }, (_, i) => + createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') }) + ) + ); + + await goToProjectsOverview(page); + await clearProjectTableState(page); + await page.reload(); + + // Default sort is name asc; first 15 projects (00–14) should be on page 1. + await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible(); + // Project 15 should be on page 2, not visible on page 1. + await expect(page.getByText(prefix + '15')).not.toBeVisible(); + + // Exactly 15 data rows should be mounted on page 1. + await expect(page.getByRole('row')).toHaveCount(15); + + // Go to page 2. + await page.getByRole('button', { name: 'Next Page' }).click(); + await expect(page.getByText(prefix + '15')).toBeVisible(); + await expect(page.getByText(prefix + '00')).not.toBeVisible(); + // Page 2 contains the remaining 2 projects (15, 16). + await expect(page.getByRole('row')).toHaveCount(2); + + // Return to page 1 via Previous Page. + await page.getByRole('button', { name: 'Previous Page' }).click(); + await expect(page.getByText(prefix + '00')).toBeVisible(); + await expect(page.getByText(prefix + '15')).not.toBeVisible(); + + // Jump to last page then back to first page. + await page.getByRole('button', { name: 'Last Page' }).click(); + await expect(page.getByText(prefix + '15')).toBeVisible(); + await page.getByRole('button', { name: 'First Page' }).click(); + await expect(page.getByText(prefix + '00')).toBeVisible(); + await expect(page.getByText(prefix + '15')).not.toBeVisible(); + + // Direct page-number button navigation. + await page.getByRole('button', { name: 'Page 2' }).click(); + await expect(page.getByText(prefix + '15')).toBeVisible(); + // Page 2 button should be marked as selected. + await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute( + 'aria-current', + 'page' + ); + }); + + test('test that project pagination is not shown when there are 15 or fewer projects', async ({ + page, + ctx, + }) => { + await Promise.all( + Array.from({ length: 10 }, (_, i) => + createProjectViaApi(ctx, { + name: `FewProj ${Math.floor(Math.random() * 100000)} ${i}`, + }) + ) + ); + + await goToProjectsOverview(page); + await clearProjectTableState(page); + await page.reload(); + + await expect(page.getByTestId('project_table')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0); + }); + + test('test that changing the sort resets pagination to page 1', async ({ page, ctx }) => { + const seed = Math.floor(Math.random() * 100000); + const prefix = `SortPagProj ${seed} `; + await Promise.all( + Array.from({ length: 17 }, (_, i) => + createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') }) + ) + ); + + await goToProjectsOverview(page); + await clearProjectTableState(page); + await page.reload(); + + await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 }); + + // Go to page 2. + await page.getByRole('button', { name: 'Next Page' }).click(); + await expect(page.getByText(prefix + '15')).toBeVisible(); + + // Sort by name descending: header click toggles asc → desc. + const nameHeader = page + .locator('[data-testid="project_table"] .select-none', { hasText: 'Name' }) + .first(); + await nameHeader.click(); + + // After sorting, pagination resets to page 1; desc order → 16, 15, ... 02 visible. + await expect(page.getByText(prefix + '16')).toBeVisible(); + await expect(page.getByText(prefix + '15')).toBeVisible(); + // Index 00 should now be on page 2 (last in desc order). + await expect(page.getByText(prefix + '00')).not.toBeVisible(); + }); +}); diff --git a/e2e/reporting-detailed.spec.ts b/e2e/reporting-detailed.spec.ts index de80c979..2c8c7f2c 100644 --- a/e2e/reporting-detailed.spec.ts +++ b/e2e/reporting-detailed.spec.ts @@ -717,3 +717,108 @@ test('test that keyboard navigation works in multiselect dropdown', async ({ pag page.getByRole('button', { name: 'Projects' }).first().getByText('1') ).toBeVisible(); }); + +// ────────────────────────────────────────────────── +// Pagination Tests +// ────────────────────────────────────────────────── + +test.describe('Reporting Detailed Pagination', () => { + test('test that detailed reporting paginates when there are more than 15 time entries', async ({ + page, + ctx, + }) => { + // The detailed report paginates server-side with a page limit of 15. + // Create 17 time entries on a single project so we get exactly 2 pages. + const seed = Math.floor(Math.random() * 100000); + const projectName = `ReportPagProj ${seed}`; + const project = await createProjectViaApi(ctx, { name: projectName }); + const descriptions = Array.from( + { length: 17 }, + (_, i) => `ReportPagEntry ${String(i).padStart(2, '0')} ${seed}` + ); + await Promise.all( + descriptions.map((description) => + createTimeEntryViaApi(ctx, { + description, + duration: '30min', + projectId: project.id, + }) + ) + ); + + await goToReportingDetailed(page); + await expect(page.getByText(descriptions[0]!).first()).toBeVisible({ + timeout: 10000, + }); + + // Pagination nav should be rendered. + await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible(); + + // Collect which descriptions are currently visible on page 1. + const visiblePage1 = new Set(); + for (const description of descriptions) { + if ((await page.getByText(description).count()) > 0) { + visiblePage1.add(description); + } + } + // The page limit is 15 → exactly 15 entries visible on page 1. + expect(visiblePage1.size).toBe(15); + + // Go to page 2 and wait for the server fetch. + await Promise.all([ + page.getByRole('button', { name: 'Next Page' }).click(), + waitForDetailedReportingUpdate(page), + ]); + + const visiblePage2 = new Set(); + for (const description of descriptions) { + if ((await page.getByText(description).count()) > 0) { + visiblePage2.add(description); + } + } + // Page 2 should hold the remaining 2 entries, disjoint from page 1. + expect(visiblePage2.size).toBe(2); + for (const description of visiblePage2) { + expect(visiblePage1.has(description)).toBe(false); + } + // Across both pages, all 17 entries should have been visible. + expect(visiblePage1.size + visiblePage2.size).toBe(17); + + // Page 2 button is selected. + await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute( + 'aria-current', + 'page' + ); + + // Previous page returns to page 1. + await Promise.all([ + page.getByRole('button', { name: 'Previous Page' }).click(), + waitForDetailedReportingUpdate(page), + ]); + expect((await page.getByText(descriptions[0]!).count()) > 0).toBe(true); + }); + + test('test that reporting pagination is not shown when there are 15 or fewer time entries', async ({ + page, + ctx, + }) => { + const seed = Math.floor(Math.random() * 100000); + const projectName = `FewEntriesProj ${seed}`; + const project = await createProjectViaApi(ctx, { name: projectName }); + await Promise.all( + Array.from({ length: 5 }, (_, i) => + createTimeEntryViaApi(ctx, { + description: `FewEntries ${i} ${seed}`, + duration: '30min', + projectId: project.id, + }) + ) + ); + + await goToReportingDetailed(page); + await expect(page.getByText(`FewEntries 0 ${seed}`).first()).toBeVisible({ + timeout: 10000, + }); + await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0); + }); +}); diff --git a/resources/js/Components/Common/Client/ClientTable.vue b/resources/js/Components/Common/Client/ClientTable.vue index 38b35b7f..3e7b5cf1 100644 --- a/resources/js/Components/Common/Client/ClientTable.vue +++ b/resources/js/Components/Common/Client/ClientTable.vue @@ -2,11 +2,12 @@ import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import { UserCircleIcon } from '@heroicons/vue/24/solid'; import { PlusIcon } from '@heroicons/vue/16/solid'; -import { type Component, computed, ref } from 'vue'; +import { type Component, computed, ref, watch } from 'vue'; import { type Client } from '@/packages/api/src'; import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue'; import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue'; import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue'; +import Pagination from '@/Components/Common/Pagination.vue'; import { canCreateClients } from '@/utils/permissions'; import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { @@ -100,6 +101,19 @@ const table = useVueTable({ const sortedClients = computed(() => { return table.getRowModel().rows.map((row) => row.original); }); + +// Client-side pagination: the full list is in memory, only one page is mounted at a time. +const PAGE_SIZE = 15; +const currentPage = ref(1); + +watch([() => props.sortColumn, () => props.sortDirection, () => props.clients], () => { + currentPage.value = 1; +}); + +const paginatedClients = computed(() => { + const start = (currentPage.value - 1) * PAGE_SIZE; + return sortedClients.value.slice(start, start + PAGE_SIZE); +});