add pagination to client and project table

This commit is contained in:
Gregor Vostrak
2026-06-28 17:39:54 +02:00
parent 981bcbe091
commit 2207f676ee
8 changed files with 488 additions and 77 deletions

View File

@@ -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 0014, page 2 shows 1516.
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 (0014) 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();
});
});

View File

@@ -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 0014, page 2 shows 1516.
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 (0014) 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();
});
});

View File

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