Add virtualizer to ProjectDropdown, ClientDropdown and Reporting

Comboboxes; Remove redundant focus loop on Project/ClientDropdown
This commit is contained in:
Gregor Vostrak
2026-06-30 16:28:09 +02:00
parent 61a0a7db5a
commit a56942abcf
6 changed files with 227 additions and 100 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -9,10 +9,10 @@ import {
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
} from 'radix-vue';
ComboboxVirtualizer,
} from 'reka-ui';
import { Check, Plus } from '@lucide/vue';
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
@@ -103,40 +103,45 @@ function updateValue(project: Project) {
</template>
<template #content>
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<div v-if="open">
<ComboboxRoot
v-model:search-term="searchValue"
v-model:open="open"
:model-value="currentProject"
class="relative"
:ignore-filter="true"
@update:model-value="updateValue">
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
v-model="searchValue"
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
placeholder="Search for a project..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
<ComboboxItem
v-for="project in shownProjects"
:key="project.id"
:value="project"
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
:data-project-id="project.id">
<span class="flex items-center gap-2">
<ComboboxVirtualizer
v-slot="{ option: project }"
:options="shownProjects"
:estimate-size="32"
:text-content="(p: Project) => p.name">
<ComboboxItem
:value="project"
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
:data-project-id="project.id">
<span class="flex min-w-0 flex-1 items-center gap-2">
<span
:style="{ backgroundColor: project.color }"
class="w-3 h-3 rounded-full shrink-0"></span>
<span class="truncate">{{ project.name }}</span>
</span>
<span
:style="{ backgroundColor: project.color }"
class="w-3 h-3 rounded-full shrink-0"></span>
<span>{{ project.name }}</span>
</span>
<span
v-if="isProjectSelected(project)"
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<Check class="h-4 w-4" />
</span>
</ComboboxItem>
v-if="isProjectSelected(project)"
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<Check class="h-4 w-4" />
</span>
</ComboboxItem>
</ComboboxVirtualizer>
</ComboboxViewport>
<div
v-if="canCreateProjects()"
@@ -150,7 +155,7 @@ function updateValue(project: Project) {
</div>
</ComboboxContent>
</ComboboxRoot>
</UseFocusTrap>
</div>
</template>
</Dropdown>
<ProjectCreateModal

View File

@@ -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<HTMLElement | null>(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<ClientRow[]>(() => [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 }) {
<slot name="trigger"></slot>
</template>
<template #content>
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<div v-if="open">
<ComboboxRoot
v-model:search-term="searchValue"
v-model:open="open"
:model-value="currentClient"
class="relative"
:ignore-filter="true"
@update:model-value="updateValue">
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
v-model="searchValue"
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
placeholder="Search for a client..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
<ComboboxItem
:value="{ id: null, name: 'No Client' }"
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground">
<span>No Client</span>
<span
v-if="model === null"
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<Check class="h-4 w-4" />
</span>
</ComboboxItem>
<ComboboxItem
v-for="client in filteredClients"
:key="client.id"
:value="client"
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
:data-client-id="client.id">
<span>{{ client.name }}</span>
<span
v-if="isClientSelected(client.id)"
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<Check class="h-4 w-4" />
</span>
</ComboboxItem>
<div
v-if="searchValue.length > 0 && filteredClients.length === 0"
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
@click="addClientIfNoneExists">
<Plus class="h-4 w-4 shrink-0" />
<span>Add "{{ searchValue }}" as a new Client</span>
</div>
<ComboboxVirtualizer
v-slot="{ option: row }"
:options="clientRows"
:estimate-size="32"
:text-content="clientRowName">
<ComboboxItem
:value="row"
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
:data-client-id="row.id">
<span class="min-w-0 flex-1 truncate">{{ row.name }}</span>
<span
v-if="model === row.id"
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<Check class="h-4 w-4" />
</span>
</ComboboxItem>
</ComboboxVirtualizer>
</ComboboxViewport>
<div
v-if="searchValue.length > 0 && filteredClients.length === 0"
class="flex items-center gap-2 rounded-sm mx-1 px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
@click="addClientIfNoneExists">
<Plus class="h-4 w-4 shrink-0" />
<span>Add "{{ searchValue }}" as a new Client</span>
</div>
</ComboboxContent>
</ComboboxRoot>
</UseFocusTrap>
</div>
</template>
</Dropdown>
</template>

View File

@@ -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<string[]>({
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<Row[]>(() => {
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']);
<slot name="trigger"></slot>
</template>
<template #content>
<ComboboxRoot
v-model:search-term="searchValue"
v-model:open="open"
class="p-2"
:filter-function="(val: string[]) => val">
<ComboboxRoot v-model:open="open" class="p-2" :ignore-filter="true">
<ComboboxAnchor>
<ComboboxInput
v-model="searchValue"
class="w-full h-8 rounded-md border border-input-border bg-input-background px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
:placeholder="searchPlaceholder" />
</ComboboxAnchor>
<ComboboxContent
:dismiss-able="false"
position="inline"
class="mt-2 min-w-60 max-w-80 max-h-60 overflow-y-auto">
<ComboboxViewport>
<ComboboxItem
v-if="showNoItem"
:value="NONE_ID"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
@select.prevent="toggleItem(NONE_ID)">
<Checkbox
:checked="model.includes(NONE_ID)"
aria-hidden="true"
:tabindex="-1"
class="pointer-events-none" />
<span class="truncate">{{ noItemLabel }}</span>
</ComboboxItem>
<ComboboxItem
v-for="item in filteredItems"
:key="getKeyFromItem(item)"
:value="getKeyFromItem(item)"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
@select.prevent="toggleItem(getKeyFromItem(item))">
<Checkbox
:checked="model.includes(getKeyFromItem(item))"
aria-hidden="true"
:tabindex="-1"
class="pointer-events-none" />
<span class="truncate">{{ getNameForItem(item) }}</span>
</ComboboxItem>
class="mt-2 min-w-60 max-w-80">
<ComboboxViewport class="max-h-60 overflow-y-auto">
<ComboboxVirtualizer
v-slot="{ option }"
:options="rows"
:estimate-size="ROW_HEIGHT"
:text-content="nameForRow">
<ComboboxItem
:value="keyForRow(option)"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
@select.prevent="toggleItem(keyForRow(option))">
<Checkbox
:checked="model.includes(keyForRow(option))"
aria-hidden="true"
:tabindex="-1"
class="pointer-events-none" />
<span class="truncate">{{ nameForRow(option) }}</span>
</ComboboxItem>
</ComboboxVirtualizer>
</ComboboxViewport>
</ComboboxContent>
</ComboboxRoot>