diff --git a/e2e/timetracker.spec.ts b/e2e/timetracker.spec.ts index de364e22..caff92cc 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 e693529e..62892e6a 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 41172b9e..ea6c8f78 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 d6c4154d..5bf06dd8 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/packages/ui/package.json b/resources/js/packages/ui/package.json index 6cba9380..068048bd 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/Project/ProjectDropdownItem.vue b/resources/js/packages/ui/src/Project/ProjectDropdownItem.vue index 22871787..be541f8f 100644 --- a/resources/js/packages/ui/src/Project/ProjectDropdownItem.vue +++ b/resources/js/packages/ui/src/Project/ProjectDropdownItem.vue @@ -9,9 +9,9 @@ defineProps<{