mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
add virtualizer to ProjectTaskDropdown component to handle bigger
project lists
This commit is contained in:
@@ -9,7 +9,14 @@ import {
|
|||||||
} from './utils/currentTimeEntry';
|
} from './utils/currentTimeEntry';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { newTagResponse } from './utils/tags';
|
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
|
// 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}$/;
|
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);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -373,6 +373,20 @@ export async function createTaskViaApi(
|
|||||||
return body.data as { id: string; name: string; project_id: string };
|
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 }) {
|
export async function createTagViaApi(ctx: TestContext, data: { name: string }) {
|
||||||
const response = await ctx.request.post(
|
const response = await ctx.request.post(
|
||||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tags`,
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tags`,
|
||||||
|
|||||||
23
package-lock.json
generated
23
package-lock.json
generated
@@ -20,6 +20,7 @@
|
|||||||
"@tanstack/vue-query": "^5.100.10",
|
"@tanstack/vue-query": "^5.100.10",
|
||||||
"@tanstack/vue-query-devtools": "^5.91.0",
|
"@tanstack/vue-query-devtools": "^5.91.0",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
"@tanstack/vue-virtual": "^3.13.24",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
@@ -5464,17 +5465,6 @@
|
|||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -8409,7 +8399,7 @@
|
|||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite-plugin-dts": "^4.0.3"
|
"vite-plugin-dts": "^4.5.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
@@ -8424,15 +8414,17 @@
|
|||||||
"version": "0.0.21",
|
"version": "0.0.21",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chroma-js": "^3.1.0",
|
"@types/chroma-js": "^3.1.2",
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
"vite-plugin-dts": "^4.0.3",
|
"vite-plugin-dts": "^4.5.4",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@floating-ui/vue": "^1.1.4",
|
"@floating-ui/vue": "^1.1.4",
|
||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@internationalized/date": "^3.0.0",
|
"@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",
|
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
|
||||||
"@vueuse/core": "^12.5.0 || ^14.0.0",
|
"@vueuse/core": "^12.5.0 || ^14.0.0",
|
||||||
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
|
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
|
||||||
@@ -8441,7 +8433,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"focus-trap": "^7.0.0 || ^8.0.0",
|
"focus-trap": "^7.0.0 || ^8.0.0",
|
||||||
"lucide-vue-next": ">=0.453.0",
|
|
||||||
"parse-duration": "^2.0.1",
|
"parse-duration": "^2.0.1",
|
||||||
"radix-vue": "^1.9.0",
|
"radix-vue": "^1.9.0",
|
||||||
"reka-ui": "^2.2.0",
|
"reka-ui": "^2.2.0",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"@tanstack/vue-query": "^5.100.10",
|
"@tanstack/vue-query": "^5.100.10",
|
||||||
"@tanstack/vue-query-devtools": "^5.91.0",
|
"@tanstack/vue-query-devtools": "^5.91.0",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
"@tanstack/vue-virtual": "^3.13.24",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
"@floating-ui/vue": "^1.1.4",
|
"@floating-ui/vue": "^1.1.4",
|
||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
|
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
|
||||||
|
"@tanstack/vue-virtual": "^3.13.24",
|
||||||
"@vueuse/core": "^12.5.0 || ^14.0.0",
|
"@vueuse/core": "^12.5.0 || ^14.0.0",
|
||||||
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
|
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
|
||||||
"focus-trap": "^7.0.0 || ^8.0.0",
|
"focus-trap": "^7.0.0 || ^8.0.0",
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex justify-between items-center w-full text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
class="flex justify-between items-center w-full text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||||
<div class="flex space-x-3 items-center px-3 py-1.5">
|
<div class="flex space-x-3 items-center px-3 py-1.5 min-w-0">
|
||||||
<div :style="{ backgroundColor: color }" class="w-3 h-3 rounded-full"></div>
|
<div :style="{ backgroundColor: color }" class="w-3 h-3 rounded-full shrink-0"></div>
|
||||||
<span>{{ name }}</span>
|
<span class="truncate">{{ name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<slot name="actions"></slot>
|
<slot name="actions"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ChevronRightIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
import { ChevronRightIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
||||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import { useVirtualizer } from '@tanstack/vue-virtual';
|
||||||
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
|
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
|
||||||
import type {
|
import type {
|
||||||
CreateClientBody,
|
CreateClientBody,
|
||||||
@@ -85,72 +86,123 @@ const filteredProjects = computed<ProjectWithTasks[]>(() => {
|
|||||||
return filteredResults.value.map((client) => client.projects).flat();
|
return filteredResults.value.map((client) => client.projects).flat();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type FlatRow =
|
||||||
|
| { kind: 'client'; key: string; name: string }
|
||||||
|
| { kind: 'project'; key: string; project: ProjectWithTasks }
|
||||||
|
| { kind: 'task'; key: string; task: Task };
|
||||||
|
|
||||||
|
// Flatten the grouped client → project → task tree into a single ordered list so it can be
|
||||||
|
// virtualized: only the rows currently inside the viewport are mounted, which keeps the
|
||||||
|
// dropdown responsive even with thousands of projects/tasks.
|
||||||
|
const flatRows = computed<FlatRow[]>(() => {
|
||||||
|
const rows: FlatRow[] = [];
|
||||||
|
for (const client of filteredResults.value) {
|
||||||
|
// The "No Project" group renders its project inline without a client header.
|
||||||
|
if (client.id !== 'no_project_no_client') {
|
||||||
|
rows.push({ kind: 'client', key: 'client-' + client.id, name: client.name });
|
||||||
|
}
|
||||||
|
for (const projectWithTasks of client.projects) {
|
||||||
|
rows.push({
|
||||||
|
kind: 'project',
|
||||||
|
key: 'project-' + projectWithTasks.id,
|
||||||
|
project: projectWithTasks,
|
||||||
|
});
|
||||||
|
if (projectWithTasks.expanded) {
|
||||||
|
for (const taskItem of projectWithTasks.tasks) {
|
||||||
|
rows.push({ kind: 'task', key: 'task-' + taskItem.id, task: taskItem });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer(
|
||||||
|
computed(() => ({
|
||||||
|
count: flatRows.value.length,
|
||||||
|
getScrollElement: () => dropdownViewport.value,
|
||||||
|
estimateSize: (index: number) => {
|
||||||
|
const row = flatRows.value[index];
|
||||||
|
if (row?.kind === 'client') return 28;
|
||||||
|
if (row?.kind === 'task') return 32;
|
||||||
|
return 38;
|
||||||
|
},
|
||||||
|
getItemKey: (index: number) => flatRows.value[index]?.key ?? index,
|
||||||
|
overscan: 12,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalSize = computed(() => rowVirtualizer.value.getTotalSize());
|
||||||
|
|
||||||
|
const visibleRows = computed(() =>
|
||||||
|
rowVirtualizer.value.getVirtualItems().map((virtualRow) => ({
|
||||||
|
virtualRow,
|
||||||
|
row: flatRows.value[virtualRow.index]!,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
function measureRow(el: unknown): void {
|
||||||
|
if (el instanceof HTMLElement) {
|
||||||
|
rowVirtualizer.value.measureElement(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup maps so filtering is O(projects + tasks + clients) instead of
|
||||||
|
// O(projects × (tasks + clients)). They are rebuilt only when the underlying task/client
|
||||||
|
// props change, not on every keystroke.
|
||||||
|
const tasksByProject = computed(() => {
|
||||||
|
const map = new Map<string, Task[]>();
|
||||||
|
for (const taskItem of props.tasks) {
|
||||||
|
const list = map.get(taskItem.project_id);
|
||||||
|
if (list) {
|
||||||
|
list.push(taskItem);
|
||||||
|
} else {
|
||||||
|
map.set(taskItem.project_id, [taskItem]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientsById = computed(() => {
|
||||||
|
const map = new Map<string, Client>();
|
||||||
|
for (const clientItem of props.clients) {
|
||||||
|
map.set(clientItem.id, clientItem);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
function addProjectToFilterObject(
|
function addProjectToFilterObject(
|
||||||
tempFilteredClients: ClientsWithProjectsWithTasks,
|
tempFilteredClients: ClientsWithProjectsWithTasks,
|
||||||
|
groupIndexByKey: Map<string, number>,
|
||||||
project: Project,
|
project: Project,
|
||||||
filteredTasks: Task[],
|
filteredTasks: Task[],
|
||||||
expanded = false
|
expanded = false
|
||||||
) {
|
) {
|
||||||
// check if client already exists in filter array
|
const client = project.client_id ? clientsById.value.get(project.client_id) : undefined;
|
||||||
const projectClientIndex = tempFilteredClients.findIndex(
|
const groupKey = client ? client.id : 'no_client';
|
||||||
(client) => client.id === project.client_id
|
const newProject: ProjectWithTasks = { ...project, expanded, tasks: filteredTasks };
|
||||||
);
|
|
||||||
|
|
||||||
const client = props.clients.find((client) => client.id === project.client_id);
|
// O(1) group lookup instead of scanning the accumulating array for every project.
|
||||||
|
const existingIndex = groupIndexByKey.get(groupKey);
|
||||||
|
if (existingIndex !== undefined) {
|
||||||
|
tempFilteredClients[existingIndex]!.projects.push(newProject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (projectClientIndex !== -1) {
|
groupIndexByKey.set(groupKey, tempFilteredClients.length);
|
||||||
// client already exists in filter array
|
if (client) {
|
||||||
tempFilteredClients[projectClientIndex]!.projects.push({
|
tempFilteredClients.push({ ...client, projects: [newProject] });
|
||||||
...project,
|
|
||||||
expanded: expanded,
|
|
||||||
tasks: filteredTasks,
|
|
||||||
});
|
|
||||||
} else if (client) {
|
|
||||||
// project has client but is not already in filter array
|
|
||||||
// client is not yet in filter array
|
|
||||||
tempFilteredClients.push({
|
|
||||||
...client,
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
...project,
|
|
||||||
expanded: expanded,
|
|
||||||
tasks: filteredTasks,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// project has no client
|
tempFilteredClients.push({
|
||||||
const customNoClientId = 'no_client';
|
id: 'no_client',
|
||||||
const noClientIndex = tempFilteredClients.findIndex(
|
name: 'No Client',
|
||||||
(client) => client.id === customNoClientId
|
color: 'var(--theme-color-icon-default)',
|
||||||
);
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
if (noClientIndex !== -1) {
|
value: '',
|
||||||
// no client group already exists in filter array
|
is_archived: false,
|
||||||
tempFilteredClients[noClientIndex]!.projects.push({
|
projects: [newProject],
|
||||||
...project,
|
});
|
||||||
expanded: expanded,
|
|
||||||
tasks: filteredTasks,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// no client group is not yet in filter array
|
|
||||||
tempFilteredClients.push({
|
|
||||||
id: customNoClientId,
|
|
||||||
name: 'No Client',
|
|
||||||
color: 'var(--theme-color-icon-default)',
|
|
||||||
created_at: '',
|
|
||||||
updated_at: '',
|
|
||||||
value: '',
|
|
||||||
is_archived: false,
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
...project,
|
|
||||||
expanded: expanded,
|
|
||||||
tasks: filteredTasks,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,26 +238,22 @@ function updateFilteredResults() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchTerm = searchValue.value?.toLowerCase()?.trim() || '';
|
||||||
|
const groupIndexByKey = new Map<string, number>();
|
||||||
|
|
||||||
for (const filterProject of props.projects) {
|
for (const filterProject of props.projects) {
|
||||||
const projectNameIncludesSearchTerm = filterProject.name
|
const projectNameIncludesSearchTerm = filterProject.name.toLowerCase().includes(searchTerm);
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
|
||||||
|
|
||||||
const clientNameIncludesSearchTerm = props.clients
|
const clientName = filterProject.client_id
|
||||||
.find((client) => client.id === filterProject.client_id)
|
? clientsById.value.get(filterProject.client_id)?.name
|
||||||
?.name.toLowerCase()
|
: undefined;
|
||||||
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
const clientNameIncludesSearchTerm = clientName?.toLowerCase().includes(searchTerm);
|
||||||
|
|
||||||
// check if one of the project tasks
|
const projectTasks = tasksByProject.value.get(filterProject.id) ?? [];
|
||||||
const projectTasks = props.tasks.filter((task) => {
|
|
||||||
return task.project_id === filterProject.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredTasks = projectTasks.filter((filterTask) => {
|
const filteredTasks = projectTasks.filter((filterTask) => {
|
||||||
return (
|
return (
|
||||||
filterTask.name
|
filterTask.name.toLowerCase().includes(searchTerm) &&
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchValue.value?.toLowerCase()?.trim() || '') &&
|
|
||||||
(!filterTask.is_done || filterTask.id === task.value)
|
(!filterTask.is_done || filterTask.id === task.value)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -215,10 +263,22 @@ function updateFilteredResults() {
|
|||||||
(!filterProject.is_archived || project.value === filterProject.id)
|
(!filterProject.is_archived || project.value === filterProject.id)
|
||||||
) {
|
) {
|
||||||
// search term matches project name
|
// search term matches project name
|
||||||
addProjectToFilterObject(tempFilteredClients, filterProject, filteredTasks, false);
|
addProjectToFilterObject(
|
||||||
|
tempFilteredClients,
|
||||||
|
groupIndexByKey,
|
||||||
|
filterProject,
|
||||||
|
filteredTasks,
|
||||||
|
false
|
||||||
|
);
|
||||||
} else if (filteredTasks.length > 0 && !filterProject.is_archived) {
|
} else if (filteredTasks.length > 0 && !filterProject.is_archived) {
|
||||||
// search term matches task name
|
// search term matches task name
|
||||||
addProjectToFilterObject(tempFilteredClients, filterProject, filteredTasks, true);
|
addProjectToFilterObject(
|
||||||
|
tempFilteredClients,
|
||||||
|
groupIndexByKey,
|
||||||
|
filterProject,
|
||||||
|
filteredTasks,
|
||||||
|
true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,24 +470,18 @@ function moveHighlightDown() {
|
|||||||
const highlightedItemId = ref<string | null>(null);
|
const highlightedItemId = ref<string | null>(null);
|
||||||
|
|
||||||
watch(highlightedItemId, () => {
|
watch(highlightedItemId, () => {
|
||||||
const highlightedItem = dropdownViewport.value?.querySelector(
|
if (highlightedItemId.value === null) {
|
||||||
`[data-project-id="${highlightedItemId.value}"]`
|
return;
|
||||||
|
}
|
||||||
|
// The highlighted row may be virtualized out of the DOM, so scroll by index
|
||||||
|
// through the virtualizer instead of querying for the element.
|
||||||
|
const index = flatRows.value.findIndex(
|
||||||
|
(row) =>
|
||||||
|
(row.kind === 'project' && row.project.id === highlightedItemId.value) ||
|
||||||
|
(row.kind === 'task' && row.task.id === highlightedItemId.value)
|
||||||
);
|
);
|
||||||
if (highlightedItem) {
|
if (index !== -1) {
|
||||||
highlightedItem.scrollIntoView({
|
rowVirtualizer.value.scrollToIndex(index, { align: 'auto' });
|
||||||
block: 'nearest',
|
|
||||||
inline: 'nearest',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const highlightedTask = dropdownViewport.value?.querySelector(
|
|
||||||
`[data-task-id="${highlightedItemId.value}"]`
|
|
||||||
);
|
|
||||||
if (highlightedTask) {
|
|
||||||
highlightedTask.scrollIntoView({
|
|
||||||
block: 'nearest',
|
|
||||||
inline: 'nearest',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -558,62 +612,65 @@ const showCreateProject = ref(false);
|
|||||||
@keydown.left.prevent="collapseProject" />
|
@keydown.left.prevent="collapseProject" />
|
||||||
<div
|
<div
|
||||||
ref="dropdownViewport"
|
ref="dropdownViewport"
|
||||||
class="min-w-[350px] max-h-[350px] overflow-y-scroll relative"
|
class="w-[400px] max-w-[calc(100vw-2rem)] max-h-[350px] overflow-y-scroll relative"
|
||||||
@mousemove="mouseEnterHighlightActivated = true">
|
@mousemove="mouseEnterHighlightActivated = true">
|
||||||
<template v-for="client in filteredResults" :key="client.id">
|
<div :style="{ height: `${totalSize}px`, width: '100%', position: 'relative' }">
|
||||||
<div
|
<div
|
||||||
v-if="client.id !== 'no_project_no_client'"
|
v-for="{ virtualRow, row } in visibleRows"
|
||||||
class="w-full pb-1 pt-2 px-2 text-text-tertiary text-xs font-semibold flex space-x-1 items-center">
|
:key="row.key"
|
||||||
<span>
|
:ref="measureRow"
|
||||||
{{ client.name }}
|
:data-index="virtualRow.index"
|
||||||
</span>
|
class="absolute left-0 top-0 w-full"
|
||||||
</div>
|
:style="{ transform: `translateY(${virtualRow.start}px)` }">
|
||||||
<template
|
|
||||||
v-for="projectWithTasks in client.projects"
|
|
||||||
:key="projectWithTasks.id">
|
|
||||||
<div
|
<div
|
||||||
|
v-if="row.kind === 'client'"
|
||||||
|
class="w-full pb-1 pt-2 px-2 text-text-tertiary text-xs font-semibold flex space-x-1 items-center">
|
||||||
|
<span class="truncate">{{ row.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="row.kind === 'project'"
|
||||||
role="option"
|
role="option"
|
||||||
class="px-1 py-0.5 cursor-default"
|
class="px-1 py-0.5 cursor-default"
|
||||||
:value="projectWithTasks.id"
|
:value="row.project.id"
|
||||||
:data-project-id="projectWithTasks.id"
|
:data-project-id="row.project.id"
|
||||||
@click="selectProject(projectWithTasks.id)">
|
@click="selectProject(row.project.id)">
|
||||||
<div
|
<div
|
||||||
class="rounded-lg"
|
class="rounded-lg"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-card-background-active':
|
'bg-card-background-active':
|
||||||
projectWithTasks.id === highlightedItemId,
|
row.project.id === highlightedItemId,
|
||||||
}">
|
}">
|
||||||
<ProjectDropdownItem
|
<ProjectDropdownItem
|
||||||
class="hover:!bg-transparent"
|
class="hover:!bg-transparent"
|
||||||
:selected="isProjectSelected(projectWithTasks)"
|
:selected="isProjectSelected(row.project)"
|
||||||
:name="projectWithTasks.name"
|
:name="row.project.name"
|
||||||
:color="projectWithTasks.color"
|
:color="row.project.color"
|
||||||
@mouseenter="setHighlightItemId(projectWithTasks.id)">
|
@mouseenter="setHighlightItemId(row.project.id)">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<button
|
<button
|
||||||
v-if="projectWithTasks.tasks.length > 0"
|
v-if="row.project.tasks.length > 0"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="px-2 py-0.5 mr-2 relative transition items-center rounded flex space-x-0.5 text-xs"
|
class="px-2 py-0.5 mr-2 relative transition items-center rounded flex space-x-0.5 text-xs shrink-0"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-white/5 text-text-secondary':
|
'bg-white/5 text-text-secondary':
|
||||||
projectWithTasks.expanded,
|
row.project.expanded,
|
||||||
'hover:bg-white/5 hover:text-text-secondary text-text-tertiary':
|
'hover:bg-white/5 hover:text-text-secondary text-text-tertiary':
|
||||||
!projectWithTasks.expanded,
|
!row.project.expanded,
|
||||||
}"
|
}"
|
||||||
@click.prevent.stop="
|
@click.prevent.stop="
|
||||||
() => {
|
() => {
|
||||||
projectWithTasks.expanded =
|
row.project.expanded =
|
||||||
!projectWithTasks.expanded;
|
!row.project.expanded;
|
||||||
searchInput?.focus();
|
searchInput?.focus();
|
||||||
}
|
}
|
||||||
">
|
">
|
||||||
<span
|
<span class="whitespace-nowrap"
|
||||||
>{{ projectWithTasks.tasks.length }} Tasks</span
|
>{{ row.project.tasks.length }} Tasks</span
|
||||||
>
|
>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
:class="{
|
:class="{
|
||||||
'transform rotate-180':
|
'transform rotate-180':
|
||||||
projectWithTasks.expanded,
|
row.project.expanded,
|
||||||
}"
|
}"
|
||||||
class="w-4"></ChevronDownIcon>
|
class="w-4"></ChevronDownIcon>
|
||||||
</button>
|
</button>
|
||||||
@@ -621,23 +678,23 @@ const showCreateProject = ref(false);
|
|||||||
</ProjectDropdownItem>
|
</ProjectDropdownItem>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="projectWithTasks.expanded" class="bg-quaternary">
|
<div
|
||||||
<div
|
v-else-if="row.kind === 'task'"
|
||||||
v-for="task in projectWithTasks.tasks"
|
:data-task-id="row.task.id"
|
||||||
:key="task.id"
|
class="flex items-center space-x-2 w-full px-5 py-1.5 text-start text-xs font-semibold leading-5 text-text-primary focus:outline-none transition duration-150 ease-in-out"
|
||||||
:data-task-id="task.id"
|
:class="
|
||||||
:class="{
|
row.task.id === highlightedItemId
|
||||||
'bg-card-background-active': task.id === highlightedItemId,
|
? 'bg-card-background-active'
|
||||||
}"
|
: 'bg-quaternary'
|
||||||
class="flex items-center space-x-2 w-full px-5 py-1.5 text-start text-xs font-semibold leading-5 text-text-primary focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
"
|
||||||
@click="selectTask(task.id)"
|
@click="selectTask(row.task.id)"
|
||||||
@mouseenter="setHighlightItemId(task.id)">
|
@mouseenter="setHighlightItemId(row.task.id)">
|
||||||
<MinusIcon class="w-3 h-3 text-text-quaternary"></MinusIcon>
|
<MinusIcon
|
||||||
<span>{{ task.name }}</span>
|
class="w-3 h-3 text-text-quaternary shrink-0"></MinusIcon>
|
||||||
</div>
|
<span class="min-w-0 truncate">{{ row.task.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="canCreateProject" class="hover:bg-card-background-active rounded-b-lg">
|
<div v-if="canCreateProject" class="hover:bg-card-background-active rounded-b-lg">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -18,3 +18,13 @@ window.getTimezoneSetting = vi.fn(() => 'UTC');
|
|||||||
window.getWeekStartSetting = vi.fn(() => 'monday');
|
window.getWeekStartSetting = vi.fn(() => 'monday');
|
||||||
window.getNumberFormat = vi.fn(() => 'point');
|
window.getNumberFormat = vi.fn(() => 'point');
|
||||||
window.getIntervalFormat = vi.fn(() => 'hours-minutes');
|
window.getIntervalFormat = vi.fn(() => 'hours-minutes');
|
||||||
|
|
||||||
|
// happy-dom has no layout engine, so every element reports offsetWidth/offsetHeight of 0.
|
||||||
|
// TanStack Virtual (used by the project/task dropdown) measures via those properties, so
|
||||||
|
// without a size it renders zero rows. Give elements a usable box so virtualized components
|
||||||
|
// render their rows in component tests.
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 400 });
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 350,
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user