diff --git a/e2e/clients.spec.ts b/e2e/clients.spec.ts index f225c188f..6d8e3d05f 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 e34e4eb5f..0d6a1442b 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -117,6 +117,43 @@ test('test that archiving and unarchiving projects works', async ({ page, ctx }) await expect(page.getByText(newProjectName)).toBeVisible(); }); +test('test that the client can be changed in the edit project modal', async ({ page, ctx }) => { + const projectName = 'Edit Client Project ' + Math.floor(1 + Math.random() * 100000); + const clientName = 'Assigned Client ' + Math.floor(1 + Math.random() * 100000); + await createProjectViaApi(ctx, { name: projectName }); + const client = await createClientViaApi(ctx, { name: clientName }); + + await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 }); + + // Open the project's Edit modal. + await page.getByRole('row').first().getByRole('button').click(); + await page.getByRole('menuitem').getByText('Edit').first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Open the client dropdown (currently "No Client"), confirm it focuses, and pick the client. + await page.getByRole('dialog').getByRole('button', { name: 'No Client' }).click(); + const clientSearch = page.getByPlaceholder('Search for a client...'); + await expect(clientSearch).toBeFocused(); + await clientSearch.fill(clientName); + await page.getByRole('option', { name: clientName }).click(); + + // The trigger updates to the chosen client. + await expect(page.getByRole('dialog').getByRole('button', { name: clientName })).toBeVisible(); + + // Saving persists the client assignment. + await Promise.all([ + page.getByRole('button', { name: 'Update Project' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.client_id === client.id + ), + ]); +}); + test('test that updating billable rate works with existing time entries', async ({ page, ctx }) => { const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000); const newBillableRate = Math.round(Math.random() * 10000); @@ -1054,3 +1091,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 de80c9797..2c8c7f2cb 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/e2e/reporting.spec.ts b/e2e/reporting.spec.ts index 85cf77b5d..d3d43852d 100644 --- a/e2e/reporting.spec.ts +++ b/e2e/reporting.spec.ts @@ -96,6 +96,37 @@ test('test that project multiselect search filters the option list', async ({ pa await page.keyboard.press('Escape'); }); +test('test that the project filter virtualizes a long list (renders only a window)', async ({ + page, + ctx, +}) => { + // Create many projects so the dropdown must virtualize rather than render all of them. + const projectNames = Array.from( + { length: 80 }, + (_, i) => `VirtProj ${String(i).padStart(2, '0')}` + ); + await Promise.all(projectNames.map((name) => createProjectViaApi(ctx, { name }))); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + await page.getByRole('button', { name: 'Projects' }).first().click(); + + // Only a small window of options is mounted, far fewer than the 80+ projects that exist. + await expect(page.getByRole('option').first()).toBeVisible(); + const renderedCount = await page.getByRole('option').count(); + expect(renderedCount).toBeGreaterThan(0); + expect(renderedCount).toBeLessThan(60); + + // Virtualization must not drop options: searching narrows the list to the one deep match. + // Wait for the filtered count to settle to 1 before asserting — checking the option while + // the virtualizer is still re-rendering can transiently match a stale row (Firefox CI flake). + await page.getByPlaceholder('Search for a Project...').fill('VirtProj 79'); + await expect(page.getByRole('option')).toHaveCount(1); + await expect(page.getByRole('option')).toContainText('VirtProj 79'); + + await page.keyboard.press('Escape'); +}); + test('test that selecting multiple projects shows correct badge count', async ({ page, ctx }) => { const project1Name = 'MultiProj1 ' + Math.floor(Math.random() * 10000); const project2Name = 'MultiProj2 ' + Math.floor(Math.random() * 10000); diff --git a/e2e/tasks.spec.ts b/e2e/tasks.spec.ts index d8abdf53b..a5bfaa217 100644 --- a/e2e/tasks.spec.ts +++ b/e2e/tasks.spec.ts @@ -152,6 +152,49 @@ test('test that editing a task name works', async ({ page, ctx }) => { await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName); }); +test('test that the project can be searched and changed in the create task modal', async ({ + page, + ctx, +}) => { + const sourceProject = 'Source Project ' + Math.floor(1 + Math.random() * 100000); + const targetProject = 'Target Project ' + Math.floor(1 + Math.random() * 100000); + await createProjectViaApi(ctx, { name: sourceProject }); + const target = await createProjectViaApi(ctx, { name: targetProject }); + + await goToProjectsOverview(page); + await page.getByText(sourceProject).first().click(); + await page.getByRole('button', { name: 'Create Task' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // The project dropdown is pre-filled with the source project; open it. + await page.getByRole('dialog').getByRole('button', { name: sourceProject }).click(); + + // Opening the dropdown focuses the search input; searching narrows it to the target project. + const projectSearch = page.getByPlaceholder('Search for a project...'); + await expect(projectSearch).toBeFocused(); + await projectSearch.fill('Target Project'); + await page.getByRole('option', { name: targetProject }).click(); + + // Selecting closes the dropdown and updates the trigger to the chosen project. + await expect( + page.getByRole('dialog').getByRole('button', { name: targetProject }) + ).toBeVisible(); + + // The new selection is what gets used when the task is created. + const taskName = 'Switched Task ' + Math.floor(1 + Math.random() * 100000); + await page.getByPlaceholder('Task Name').fill(taskName); + await Promise.all([ + page.getByRole('dialog').getByRole('button', { name: 'Create Task' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/tasks') && + response.request().method() === 'POST' && + response.status() === 201 && + (await response.json()).data.project_id === target.id + ), + ]); +}); + test('test that creating a project with an existing client works', async ({ page, ctx }) => { const clientName = 'Existing Client ' + Math.floor(1 + Math.random() * 10000); const projectName = 'Project With Client ' + Math.floor(1 + Math.random() * 10000); diff --git a/e2e/timetracker.spec.ts b/e2e/timetracker.spec.ts index de364e22e..caff92cc3 100644 --- a/e2e/timetracker.spec.ts +++ b/e2e/timetracker.spec.ts @@ -9,7 +9,14 @@ import { } from './utils/currentTimeEntry'; import type { Page } from '@playwright/test'; import { newTagResponse } from './utils/tags'; -import { createProjectViaApi, updateOrganizationCurrencyViaWeb } from './utils/api'; +import { + createProjectViaApi, + createTaskViaApi, + createClientViaApi, + archiveProjectViaApi, + markTaskDoneViaApi, + updateOrganizationCurrencyViaWeb, +} from './utils/api'; // Date picker button name patterns for different date formats const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/; @@ -441,3 +448,231 @@ test('test that adding a project and tag before starting timer works', async ({ ]); await assertThatTimerIsStopped(page); }); + +// ────────────────────────────────────────────────── +// Project / Task selector dropdown +// Regression coverage for the virtualized + lookup-map refactor of +// TimeTrackerProjectTaskDropdown. The dropdown only (re)filters on open and on search +// change, so we wait for the dashboard prefetch to settle before opening it. +// ────────────────────────────────────────────────── + +test.describe('Project Task Dropdown', () => { + test.describe.configure({ timeout: 60_000 }); + + test('test that a project far down a long list can be found via search and selected', async ({ + page, + ctx, + }) => { + // Seed enough projects that the target sits outside the initially rendered window. + const seed = Math.floor(Math.random() * 100000); + const prefix = `VirtProj ${seed} `; + await Promise.all( + Array.from({ length: 30 }, (_, i) => + createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') }) + ) + ); + const target = prefix + '27'; + + await goToDashboard(page); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: 'No Project' }).click(); + await page.getByTestId('client_dropdown_search').fill(target); + await page.getByRole('option').filter({ hasText: target }).click(); + + // The trigger now reflects the selected project. + await expect(page.getByRole('button', { name: target })).toBeVisible(); + }); + + test('test that expanding a project and selecting a task works', async ({ page, ctx }) => { + const seed = Math.floor(Math.random() * 100000); + const projectName = `ExpandProj ${seed}`; + const taskName = `ExpandTask ${seed}`; + const project = await createProjectViaApi(ctx, { name: projectName }); + await createTaskViaApi(ctx, { name: taskName, project_id: project.id }); + + await goToDashboard(page); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: 'No Project' }).click(); + const projectOption = page.getByRole('option').filter({ hasText: projectName }); + await expect(projectOption).toBeVisible(); + + // Expand the project's tasks via the "N Tasks" button, then select the task. + await projectOption.getByText(/Tasks/).click(); + await page.getByText(taskName, { exact: true }).click(); + + // The trigger reflects the selected task. + await expect(page.getByText(taskName)).toBeVisible(); + }); + + test('test that keyboard navigation selects a project', async ({ page, ctx }) => { + const seed = Math.floor(Math.random() * 100000); + const projectName = `KbProj ${seed}`; + await createProjectViaApi(ctx, { name: projectName }); + + await goToDashboard(page); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: 'No Project' }).click(); + const search = page.getByTestId('client_dropdown_search'); + // On open the search is focused and "No Project" is highlighted. + await expect(search).toBeFocused(); + + // Arrow down from "No Project" to the project, then select it with Enter. + await search.press('ArrowDown'); + await search.press('Enter'); + + await expect(page.getByRole('button', { name: projectName })).toBeVisible(); + }); + + test('test that search filters the dropdown by project and client name', async ({ + page, + ctx, + }) => { + const seed = Math.floor(Math.random() * 100000); + const clientName = `FilterClient ${seed}`; + const alphaProject = `AlphaProj ${seed}`; + const betaProject = `BetaProj ${seed}`; + const client = await createClientViaApi(ctx, { name: clientName }); + await createProjectViaApi(ctx, { name: alphaProject, client_id: client.id }); + await createProjectViaApi(ctx, { name: betaProject }); + + await goToDashboard(page); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: 'No Project' }).click(); + const search = page.getByTestId('client_dropdown_search'); + const alphaOption = page.getByRole('option').filter({ hasText: alphaProject }); + const betaOption = page.getByRole('option').filter({ hasText: betaProject }); + + // Both projects are visible before filtering. + await expect(alphaOption).toBeVisible(); + await expect(betaOption).toBeVisible(); + + // Project-name search shows only the matching project. + await search.fill('AlphaProj'); + await expect(alphaOption).toBeVisible(); + await expect(betaOption).not.toBeVisible(); + + // Client-name search shows the project that belongs to that client. + await search.fill(clientName); + await expect(alphaOption).toBeVisible(); + await expect(betaOption).not.toBeVisible(); + }); + + test("test that searching by task name surfaces the task's project", async ({ page, ctx }) => { + const seed = Math.floor(Math.random() * 100000); + const projectWithTask = `TaskSearchProj ${seed}`; + const taskName = `Findable Task ${seed}`; + const unrelatedProject = `Unrelated Proj ${seed}`; + const project = await createProjectViaApi(ctx, { name: projectWithTask }); + await createTaskViaApi(ctx, { name: taskName, project_id: project.id }); + await createProjectViaApi(ctx, { name: unrelatedProject }); + + await goToDashboard(page); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: 'No Project' }).click(); + await page.getByTestId('client_dropdown_search').fill(taskName); + + // The project owning the task is shown (with the task), the unrelated project is not. + await expect(page.getByRole('option').filter({ hasText: projectWithTask })).toBeVisible(); + await expect(page.getByText(taskName, { exact: true })).toBeVisible(); + await expect( + page.getByRole('option').filter({ hasText: unrelatedProject }) + ).not.toBeVisible(); + }); + + test('test that archived projects are hidden from the dropdown', async ({ page, ctx }) => { + const seed = Math.floor(Math.random() * 100000); + const activeProject = `ActiveProj ${seed}`; + const archivedProject = `ArchivedProj ${seed}`; + await createProjectViaApi(ctx, { name: activeProject }); + const toArchive = await createProjectViaApi(ctx, { name: archivedProject }); + await archiveProjectViaApi(ctx, toArchive); + + await goToDashboard(page); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: 'No Project' }).click(); + + // Wait for the list to load, then confirm the archived project is filtered out. + await expect(page.getByRole('option').filter({ hasText: activeProject })).toBeVisible(); + await expect( + page.getByRole('option').filter({ hasText: archivedProject }) + ).not.toBeVisible(); + }); + + test('test that done tasks are hidden when expanding a project', async ({ page, ctx }) => { + const seed = Math.floor(Math.random() * 100000); + const projectName = `DoneTaskProj ${seed}`; + const activeTask = `Active Task ${seed}`; + const doneTask = `Done Task ${seed}`; + const project = await createProjectViaApi(ctx, { name: projectName }); + await createTaskViaApi(ctx, { name: activeTask, project_id: project.id }); + const taskToFinish = await createTaskViaApi(ctx, { + name: doneTask, + project_id: project.id, + }); + await markTaskDoneViaApi(ctx, taskToFinish); + + await goToDashboard(page); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: 'No Project' }).click(); + const projectOption = page.getByRole('option').filter({ hasText: projectName }); + await expect(projectOption).toBeVisible(); + await projectOption.getByText(/Tasks/).click(); + + // Only the active task shows; the done task is filtered out. + await expect(page.getByText(activeTask, { exact: true })).toBeVisible(); + await expect(page.getByText(doneTask, { exact: true })).not.toBeVisible(); + }); + + test('test that keyboard navigation can expand a project and select a task', async ({ + page, + ctx, + }) => { + const seed = Math.floor(Math.random() * 100000); + const projectName = `KbTaskProj ${seed}`; + const taskName = `KbTask ${seed}`; + const project = await createProjectViaApi(ctx, { name: projectName }); + await createTaskViaApi(ctx, { name: taskName, project_id: project.id }); + + await goToDashboard(page); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: 'No Project' }).click(); + const search = page.getByTestId('client_dropdown_search'); + await expect(search).toBeFocused(); + + // No Project is highlighted on open: down to the project, right to expand its tasks, + // down to the task, Enter to select it. + await search.press('ArrowDown'); + await search.press('ArrowRight'); + await search.press('ArrowDown'); + await search.press('Enter'); + + await expect(page.getByText(taskName)).toBeVisible(); + }); + + test('test that pressing space selects the highlighted project', async ({ page, ctx }) => { + const seed = Math.floor(Math.random() * 100000); + const projectName = `SpaceProj ${seed}`; + await createProjectViaApi(ctx, { name: projectName }); + + await goToDashboard(page); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: 'No Project' }).click(); + const search = page.getByTestId('client_dropdown_search'); + await expect(search).toBeFocused(); + + // Arrow down from "No Project" to the project, then the space shortcut selects it. + await search.press('ArrowDown'); + await search.press('Space'); + + await expect(page.getByRole('button', { name: projectName })).toBeVisible(); + }); +}); diff --git a/e2e/utils/api.ts b/e2e/utils/api.ts index e693529e6..62892e6a7 100644 --- a/e2e/utils/api.ts +++ b/e2e/utils/api.ts @@ -373,6 +373,20 @@ export async function createTaskViaApi( return body.data as { id: string; name: string; project_id: string }; } +export async function markTaskDoneViaApi(ctx: TestContext, task: { id: string; name: string }) { + const response = await ctx.request.put( + `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tasks/${task.id}`, + { + data: { + name: task.name, + is_done: true, + }, + } + ); + expect(response.status()).toBe(200); + return (await response.json()).data; +} + export async function createTagViaApi(ctx: TestContext, data: { name: string }) { const response = await ctx.request.post( `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tags`, diff --git a/package-lock.json b/package-lock.json index 41172b9e5..ea6c8f783 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@tanstack/vue-query": "^5.100.10", "@tanstack/vue-query-devtools": "^5.91.0", "@tanstack/vue-table": "^8.21.3", + "@tanstack/vue-virtual": "^3.13.24", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.7.0", "@vueuse/core": "^14.3.0", @@ -5464,17 +5465,6 @@ "yallist": "^3.0.2" } }, - "node_modules/lucide-vue-next": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz", - "integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==", - "deprecated": "Package deprecated. Please use @lucide/vue instead.", - "license": "ISC", - "peer": true, - "peerDependencies": { - "vue": ">=3.0.1" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -8409,7 +8399,7 @@ "version": "0.0.6", "license": "AGPL-3.0", "devDependencies": { - "vite-plugin-dts": "^4.0.3" + "vite-plugin-dts": "^4.5.4" }, "peerDependencies": { "@zodios/core": "^10.9.6", @@ -8424,15 +8414,17 @@ "version": "0.0.21", "license": "AGPL-3.0", "devDependencies": { - "@types/chroma-js": "^3.1.0", + "@types/chroma-js": "^3.1.2", "@zodios/core": "^10.9.6", - "vite-plugin-dts": "^4.0.3", - "zod": "^3.23.8" + "vite-plugin-dts": "^4.5.4", + "zod": "^3.25.76" }, "peerDependencies": { "@floating-ui/vue": "^1.1.4", "@heroicons/vue": "^2.1.5", "@internationalized/date": "^3.0.0", + "@lucide/vue": ">=1.0.0", + "@tanstack/vue-virtual": "^3.13.24", "@vitejs/plugin-vue": "^5.1.2 || ^6.0.0", "@vueuse/core": "^12.5.0 || ^14.0.0", "@vueuse/integrations": "^12.5.0 || ^14.0.0", @@ -8441,7 +8433,6 @@ "clsx": "^2.1.1", "dayjs": "^1.11.13", "focus-trap": "^7.0.0 || ^8.0.0", - "lucide-vue-next": ">=0.453.0", "parse-duration": "^2.0.1", "radix-vue": "^1.9.0", "reka-ui": "^2.2.0", diff --git a/package.json b/package.json index d6c4154d6..5bf06dd87 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@tanstack/vue-query": "^5.100.10", "@tanstack/vue-query-devtools": "^5.91.0", "@tanstack/vue-table": "^8.21.3", + "@tanstack/vue-virtual": "^3.13.24", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.7.0", "@vueuse/core": "^14.3.0", diff --git a/resources/js/Components/Common/Client/ClientTable.vue b/resources/js/Components/Common/Client/ClientTable.vue index 38b35b7fa..3e7b5cf11 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); +}); { 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.projects], () => { + currentPage.value = 1; +}); + +const paginatedProjects = computed(() => { + const start = (currentPage.value - 1) * PAGE_SIZE; + return sortedProjects.value.slice(start, start + PAGE_SIZE); +}); + const showCreateProjectModal = ref(false); async function createProject(project: CreateProjectBody): Promise { @@ -199,7 +213,7 @@ const gridTemplate = computed(() => { >Create your First Project - - diff --git a/resources/js/packages/ui/package.json b/resources/js/packages/ui/package.json index 6cba93803..068048bda 100644 --- a/resources/js/packages/ui/package.json +++ b/resources/js/packages/ui/package.json @@ -57,6 +57,7 @@ "@floating-ui/vue": "^1.1.4", "@heroicons/vue": "^2.1.5", "@vitejs/plugin-vue": "^5.1.2 || ^6.0.0", + "@tanstack/vue-virtual": "^3.13.24", "@vueuse/core": "^12.5.0 || ^14.0.0", "@vueuse/integrations": "^12.5.0 || ^14.0.0", "focus-trap": "^7.0.0 || ^8.0.0", diff --git a/resources/js/packages/ui/src/Client/ClientDropdown.vue b/resources/js/packages/ui/src/Client/ClientDropdown.vue index 7586e9445..b8b0598c4 100644 --- a/resources/js/packages/ui/src/Client/ClientDropdown.vue +++ b/resources/js/packages/ui/src/Client/ClientDropdown.vue @@ -8,8 +8,8 @@ import { ComboboxItem, ComboboxRoot, ComboboxViewport, -} from 'radix-vue'; -import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component'; + ComboboxVirtualizer, +} from 'reka-ui'; import Dropdown from '@/packages/ui/src/Input/Dropdown.vue'; import { Check, Plus } from '@lucide/vue'; @@ -26,10 +26,6 @@ const searchInput = ref(null); const open = ref(false); const searchValue = ref(''); -function isClientSelected(id: string) { - return model.value === id; -} - watch(open, (isOpen) => { if (isOpen) { nextTick(() => { @@ -58,15 +54,23 @@ async function addClientIfNoneExists() { } } +const NO_CLIENT: { id: string | null; name: string } = { id: null, name: 'No Client' }; + const currentClient = computed(() => { - return ( - props.clients.find((client) => client.id === model.value) ?? { - id: null, - name: 'No Client', - } - ); + return props.clients.find((client) => client.id === model.value) ?? NO_CLIENT; }); +type ClientRow = Client | typeof NO_CLIENT; + +// Fold the "No Client" entry in as the first row so the whole list virtualizes through one +// ComboboxVirtualizer. NO_CLIENT is a shared constant so currentClient and the row reference +// the same object and single-select highlighting still matches. +const clientRows = computed(() => [NO_CLIENT, ...filteredClients.value]); + +function clientRowName(row: ClientRow) { + return row.name; +} + const emit = defineEmits(['update:modelValue', 'changed']); function updateValue(client: { id: string | null; name: string }) { @@ -81,56 +85,51 @@ function updateValue(client: { id: string | null; name: string }) { diff --git a/resources/js/packages/ui/src/Input/MultiselectDropdown.vue b/resources/js/packages/ui/src/Input/MultiselectDropdown.vue index 969cd41d0..9fd314b84 100644 --- a/resources/js/packages/ui/src/Input/MultiselectDropdown.vue +++ b/resources/js/packages/ui/src/Input/MultiselectDropdown.vue @@ -9,10 +9,16 @@ import { ComboboxItem, ComboboxRoot, ComboboxViewport, -} from 'radix-vue'; + ComboboxVirtualizer, +} from 'reka-ui'; const NONE_ID = 'none'; +// height of one row (px-2 py-1.5 text-sm → 12px padding + 20px line box). +// Rows are uniform single-line, so a fixed size is exact enough for the virtualizer and avoids +// any per-row DOM measurement. +const ROW_HEIGHT = 32; + const model = defineModel({ default: [], }); @@ -56,6 +62,23 @@ const showNoItem = computed(() => { return props.noItemLabel.toLowerCase().includes(search); }); +// A single flat list for the virtualizer. The optional "no item" entry is folded in as the +// first row so the whole list (including it) is virtualized through one ComboboxVirtualizer. +type Row = { kind: 'none' } | { kind: 'item'; item: T }; + +const rows = computed(() => { + const itemRows = filteredItems.value.map((item): Row => ({ kind: 'item', item })); + return showNoItem.value ? [{ kind: 'none' }, ...itemRows] : itemRows; +}); + +function keyForRow(row: Row): string { + return row.kind === 'none' ? NONE_ID : props.getKeyFromItem(row.item); +} + +function nameForRow(row: Row): string { + return row.kind === 'none' ? (props.noItemLabel ?? '') : props.getNameForItem(row.item); +} + function toggleItem(id: string) { if (model.value.includes(id)) { model.value = model.value.filter((itemId) => itemId !== id); @@ -74,46 +97,35 @@ const emit = defineEmits(['update:modelValue', 'changed', 'submit']);