Compare commits

..

1 Commits

Author SHA1 Message Date
Constantin Graf
819dfd5037 Allow Clockify import without Billable column 2026-06-24 12:11:46 +02:00
31 changed files with 347 additions and 1356 deletions

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
@@ -25,7 +24,6 @@ class ClockifyProjectsImporter extends DefaultImporter
$header = $reader->getHeader();
$this->validateHeader($header);
$billableRateKey = $this->getBillableRateKey($header);
$tasksKey = $this->getTasksKey($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$clientId = null;
@@ -46,12 +44,11 @@ class ClockifyProjectsImporter extends DefaultImporter
'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
]);
}
if ($tasksKey !== null && $record[$tasksKey] !== '') {
$tasks = explode(', ', $record[$tasksKey]);
if ($record['Task'] !== '') {
$tasks = explode(', ', $record['Task']);
foreach ($tasks as $task) {
$this->taskImportHelper->getKey([
'name' => $task,
@@ -84,33 +81,13 @@ class ClockifyProjectsImporter extends DefaultImporter
'Status',
'Visibility',
'Billability',
'Task',
];
foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
// Clockify names the tasks column "Task", "Tasks" or "Activities" depending on the export; accept any.
if ($this->getTasksKey($header) === null) {
throw new ImportException('Invalid CSV header, missing field: Tasks');
}
}
/**
* Clockify names the tasks column differently depending on the export
* version: "Task" (older), "Tasks" (newer) or "Activities".
*
* @param array<string> $header
*/
private function getTasksKey(array $header): ?string
{
foreach (['Tasks', 'Task', 'Activities'] as $field) {
if (in_array($field, $header, true)) {
return $field;
}
}
return null;
}
/**

View File

@@ -54,7 +54,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$taskKey = $this->getTaskKey($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$userId = $this->userImportHelper->getKey([
@@ -97,9 +96,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]);
}
$taskId = null;
if ($taskKey !== null && $record[$taskKey] !== '') {
if ($record['Task'] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record[$taskKey],
'name' => $record['Task'],
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
@@ -217,6 +216,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'Project',
'Client',
'Description',
'Task',
'User',
'Group',
'Email',
@@ -231,26 +231,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
// Clockify names the task column "Task" or "Activity" depending on the export; accept either.
if ($this->getTaskKey($header) === null) {
throw new ImportException('Invalid CSV header, missing field: Task');
}
}
/**
* Clockify names the task column "Task" or "Activity" depending on the export version.
*
* @param array<string> $header
*/
private function getTaskKey(array $header): ?string
{
foreach (['Task', 'Activity'] as $field) {
if (in_array($field, $header, true)) {
return $field;
}
}
return null;
}
#[\Override]

View File

@@ -246,7 +246,7 @@ test('test that sorting clients by name and status works', async ({ page, ctx })
test('test that sorting clients by project count works', async ({ page, ctx }) => {
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
await createClientViaApi(ctx, { name: 'NoProjects Client' });
const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });
// Create projects for the first client
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
@@ -374,119 +374,3 @@ test.describe('Employee Clients Restrictions', () => {
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
});
});
// ──────────────────────────────────────────────────
// Pagination Tests
// ──────────────────────────────────────────────────
test.describe('Clients Pagination', () => {
test.describe.configure({ timeout: 30000 });
test('test that client table paginates when there are more than 15 clients', async ({
page,
ctx,
}) => {
// Create 17 clients with zero-padded names so alphabetical sort is predictable.
// Page size is 15 → page 1 shows indices 0014, page 2 shows 1516.
const seed = Math.floor(Math.random() * 100000);
const prefix = `PaginationClient ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createClientViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
// Default sort is name asc; first 15 clients (0014) on page 1.
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
// Client 15 should be on page 2, not visible on page 1.
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Exactly 15 data rows mounted on page 1.
await expect(page.getByRole('row')).toHaveCount(15);
// Navigation to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByText(prefix + '00')).not.toBeVisible();
// Page 2 contains the remaining 2 clients.
await expect(page.getByRole('row')).toHaveCount(2);
// Back to page 1 via Previous Page.
await page.getByRole('button', { name: 'Previous Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// First / Last page jumps.
await page.getByRole('button', { name: 'Last Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await page.getByRole('button', { name: 'First Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Direct page-number button navigation + selected state.
await page.getByRole('button', { name: 'Page 2' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
'aria-current',
'page'
);
});
test('test that client pagination is not shown when there are 15 or fewer clients', async ({
page,
ctx,
}) => {
await Promise.all(
Array.from({ length: 10 }, (_, i) =>
createClientViaApi(ctx, {
name: `FewClient ${Math.floor(Math.random() * 100000)} ${i}`,
})
)
);
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
await expect(page.getByTestId('client_table')).toBeVisible();
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
});
test('test that changing the sort resets client pagination to page 1', async ({
page,
ctx,
}) => {
const seed = Math.floor(Math.random() * 100000);
const prefix = `SortPagClient ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createClientViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
// Go to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Sort by name descending.
const table = page.getByTestId('client_table');
const nameHeader = table.getByText('Name').first();
await nameHeader.click();
// Pagination reset to page 1; desc order → 16, 15 visible, 00 on page 2.
await expect(page.getByText(prefix + '16')).toBeVisible();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByText(prefix + '00')).not.toBeVisible();
});
});

View File

@@ -117,43 +117,6 @@ 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);
@@ -1091,119 +1054,3 @@ test.describe('Employee Billable Rate Visibility', () => {
await expect(projectRow).toContainText('200');
});
});
// ──────────────────────────────────────────────────
// Pagination Tests
// ──────────────────────────────────────────────────
test.describe('Projects Pagination', () => {
test.describe.configure({ timeout: 30000 });
test('test that project table paginates when there are more than 15 projects', async ({
page,
ctx,
}) => {
// Create 17 projects with zero-padded names so alphabetical sort is predictable.
// Page size is 15 → page 1 shows indices 0014, page 2 shows 1516.
const seed = Math.floor(Math.random() * 100000);
const prefix = `PaginationProj ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Default sort is name asc; first 15 projects (0014) should be on page 1.
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
// Project 15 should be on page 2, not visible on page 1.
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Exactly 15 data rows should be mounted on page 1.
await expect(page.getByRole('row')).toHaveCount(15);
// Go to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByText(prefix + '00')).not.toBeVisible();
// Page 2 contains the remaining 2 projects (15, 16).
await expect(page.getByRole('row')).toHaveCount(2);
// Return to page 1 via Previous Page.
await page.getByRole('button', { name: 'Previous Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Jump to last page then back to first page.
await page.getByRole('button', { name: 'Last Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await page.getByRole('button', { name: 'First Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Direct page-number button navigation.
await page.getByRole('button', { name: 'Page 2' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Page 2 button should be marked as selected.
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
'aria-current',
'page'
);
});
test('test that project pagination is not shown when there are 15 or fewer projects', async ({
page,
ctx,
}) => {
await Promise.all(
Array.from({ length: 10 }, (_, i) =>
createProjectViaApi(ctx, {
name: `FewProj ${Math.floor(Math.random() * 100000)} ${i}`,
})
)
);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
await expect(page.getByTestId('project_table')).toBeVisible();
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
});
test('test that changing the sort resets pagination to page 1', async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const prefix = `SortPagProj ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
// Go to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Sort by name descending: header click toggles asc → desc.
const nameHeader = page
.locator('[data-testid="project_table"] .select-none', { hasText: 'Name' })
.first();
await nameHeader.click();
// After sorting, pagination resets to page 1; desc order → 16, 15, ... 02 visible.
await expect(page.getByText(prefix + '16')).toBeVisible();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Index 00 should now be on page 2 (last in desc order).
await expect(page.getByText(prefix + '00')).not.toBeVisible();
});
});

View File

@@ -717,108 +717,3 @@ test('test that keyboard navigation works in multiselect dropdown', async ({ pag
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
).toBeVisible();
});
// ──────────────────────────────────────────────────
// Pagination Tests
// ──────────────────────────────────────────────────
test.describe('Reporting Detailed Pagination', () => {
test('test that detailed reporting paginates when there are more than 15 time entries', async ({
page,
ctx,
}) => {
// The detailed report paginates server-side with a page limit of 15.
// Create 17 time entries on a single project so we get exactly 2 pages.
const seed = Math.floor(Math.random() * 100000);
const projectName = `ReportPagProj ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectName });
const descriptions = Array.from(
{ length: 17 },
(_, i) => `ReportPagEntry ${String(i).padStart(2, '0')} ${seed}`
);
await Promise.all(
descriptions.map((description) =>
createTimeEntryViaApi(ctx, {
description,
duration: '30min',
projectId: project.id,
})
)
);
await goToReportingDetailed(page);
await expect(page.getByText(descriptions[0]!).first()).toBeVisible({
timeout: 10000,
});
// Pagination nav should be rendered.
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
// Collect which descriptions are currently visible on page 1.
const visiblePage1 = new Set<string>();
for (const description of descriptions) {
if ((await page.getByText(description).count()) > 0) {
visiblePage1.add(description);
}
}
// The page limit is 15 → exactly 15 entries visible on page 1.
expect(visiblePage1.size).toBe(15);
// Go to page 2 and wait for the server fetch.
await Promise.all([
page.getByRole('button', { name: 'Next Page' }).click(),
waitForDetailedReportingUpdate(page),
]);
const visiblePage2 = new Set<string>();
for (const description of descriptions) {
if ((await page.getByText(description).count()) > 0) {
visiblePage2.add(description);
}
}
// Page 2 should hold the remaining 2 entries, disjoint from page 1.
expect(visiblePage2.size).toBe(2);
for (const description of visiblePage2) {
expect(visiblePage1.has(description)).toBe(false);
}
// Across both pages, all 17 entries should have been visible.
expect(visiblePage1.size + visiblePage2.size).toBe(17);
// Page 2 button is selected.
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
'aria-current',
'page'
);
// Previous page returns to page 1.
await Promise.all([
page.getByRole('button', { name: 'Previous Page' }).click(),
waitForDetailedReportingUpdate(page),
]);
expect((await page.getByText(descriptions[0]!).count()) > 0).toBe(true);
});
test('test that reporting pagination is not shown when there are 15 or fewer time entries', async ({
page,
ctx,
}) => {
const seed = Math.floor(Math.random() * 100000);
const projectName = `FewEntriesProj ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectName });
await Promise.all(
Array.from({ length: 5 }, (_, i) =>
createTimeEntryViaApi(ctx, {
description: `FewEntries ${i} ${seed}`,
duration: '30min',
projectId: project.id,
})
)
);
await goToReportingDetailed(page);
await expect(page.getByText(`FewEntries 0 ${seed}`).first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
});
});

View File

@@ -96,37 +96,6 @@ 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,49 +152,6 @@ 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,14 +9,7 @@ import {
} from './utils/currentTimeEntry';
import type { Page } from '@playwright/test';
import { newTagResponse } from './utils/tags';
import {
createProjectViaApi,
createTaskViaApi,
createClientViaApi,
archiveProjectViaApi,
markTaskDoneViaApi,
updateOrganizationCurrencyViaWeb,
} from './utils/api';
import { createProjectViaApi, 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}$/;
@@ -448,231 +441,3 @@ 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();
});
});

View File

@@ -373,20 +373,6 @@ 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`,

View File

@@ -5,8 +5,7 @@ declare(strict_types=1);
return [
'clockify_time_entries' => [
'name' => 'Clockify Time Entries',
'description' => '<strong>Important:</strong> If you also want to import your projects use the "Clockify Projects" importer before this one, since that export contains more details such as billable status, billable rates and estimated time.<br><br>'.
'1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
'2. In the same preferences page change the language of Clockfiy to English.<br>'.
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'4. Now select the date range that you want to export in the right top. '.
@@ -62,8 +61,7 @@ return [
],
'harvest_time_entries' => [
'name' => 'Harvest Time Entries',
'description' => '<strong>Important:</strong> If you also want to import your projects use the "Harvest Projects" importer before this one, since that export contains more details such as billable status and estimated time.<br><br>'.
'1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
'description' => '1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
'<br>3. Now click on "Export all time" '.
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
],

23
package-lock.json generated
View File

@@ -20,7 +20,6 @@
"@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",
@@ -5465,6 +5464,17 @@
"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",
@@ -8399,7 +8409,7 @@
"version": "0.0.6",
"license": "AGPL-3.0",
"devDependencies": {
"vite-plugin-dts": "^4.5.4"
"vite-plugin-dts": "^4.0.3"
},
"peerDependencies": {
"@zodios/core": "^10.9.6",
@@ -8414,17 +8424,15 @@
"version": "0.0.21",
"license": "AGPL-3.0",
"devDependencies": {
"@types/chroma-js": "^3.1.2",
"@types/chroma-js": "^3.1.0",
"@zodios/core": "^10.9.6",
"vite-plugin-dts": "^4.5.4",
"zod": "^3.25.76"
"vite-plugin-dts": "^4.0.3",
"zod": "^3.23.8"
},
"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",
@@ -8433,6 +8441,7 @@
"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",

View File

@@ -64,7 +64,6 @@
"@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",

View File

@@ -2,12 +2,11 @@
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, watch } from 'vue';
import { type Component, computed, ref } 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 {
@@ -101,19 +100,6 @@ 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);
});
</script>
<template>
@@ -140,14 +126,10 @@ const paginatedClients = computed(() => {
>Create your First Client
</SecondaryButton>
</div>
<template v-for="client in paginatedClients" :key="client.id">
<template v-for="client in sortedClients" :key="client.id">
<ClientTableRow :client="client"></ClientTableRow>
</template>
</div>
</div>
</div>
<Pagination
v-model:page="currentPage"
:total="sortedClients.length"
:items-per-page="PAGE_SIZE"></Pagination>
</template>

View File

@@ -1,104 +0,0 @@
<script setup lang="ts">
import {
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot,
} from 'radix-vue';
import {
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
EllipsisHorizontalIcon,
} from '@heroicons/vue/20/solid';
import { buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { computed, watch } from 'vue';
const page = defineModel<number>('page', { default: 1 });
const props = withDefaults(
defineProps<{
total: number;
itemsPerPage?: number;
siblingCount?: number;
showEdges?: boolean;
}>(),
{
itemsPerPage: 15,
siblingCount: 1,
showEdges: true,
}
);
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.itemsPerPage)));
watch(page, (value) => {
if (value > pageCount.value) {
page.value = pageCount.value;
}
});
watch(pageCount, (value) => {
if (page.value > value) {
page.value = value;
}
});
// The shared buttonVariants ghost/outline hover is `bg-white/5`, which is invisible in light
// mode. Override it with a theme-aware hover that shows in both light and dark mode.
const hoverClass = 'hover:bg-black/5 dark:hover:bg-white/5';
const navButtonClass = cn(buttonVariants({ variant: 'ghost', size: 'icon' }), hoverClass);
function pageButtonClass(isActive: boolean): string {
return cn(
buttonVariants({ variant: isActive ? 'outline' : 'ghost', size: 'icon' }),
hoverClass
);
}
</script>
<template>
<PaginationRoot
v-if="pageCount > 1"
v-model:page="page"
:total="props.total"
:items-per-page="props.itemsPerPage"
:sibling-count="props.siblingCount"
:show-edges="props.showEdges"
class="mx-auto flex w-full justify-center py-8">
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<PaginationFirst :class="navButtonClass">
<ChevronDoubleLeftIcon class="size-4" />
</PaginationFirst>
<PaginationPrev :class="navButtonClass">
<ChevronLeftIcon class="size-4" />
</PaginationPrev>
<template v-for="(item, index) in items" :key="index">
<PaginationListItem
v-if="item.type === 'page'"
:value="item.value"
:class="pageButtonClass(item.value === page)">
{{ item.value }}
</PaginationListItem>
<PaginationEllipsis
v-else
:index="index"
class="flex size-9 items-center justify-center text-text-tertiary">
<EllipsisHorizontalIcon class="size-4" />
</PaginationEllipsis>
</template>
<PaginationNext :class="navButtonClass">
<ChevronRightIcon class="size-4" />
</PaginationNext>
<PaginationLast :class="navButtonClass">
<ChevronDoubleRightIcon class="size-4" />
</PaginationLast>
</PaginationList>
</PaginationRoot>
</template>

View File

@@ -9,10 +9,10 @@ import {
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
ComboboxVirtualizer,
} from 'reka-ui';
} from 'radix-vue';
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,45 +103,40 @@ function updateValue(project: Project) {
</template>
<template #content>
<div v-if="open">
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<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">
<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>
<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">
<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>
</ComboboxVirtualizer>
: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>
</ComboboxViewport>
<div
v-if="canCreateProjects()"
@@ -155,7 +150,7 @@ function updateValue(project: Project) {
</div>
</ComboboxContent>
</ComboboxRoot>
</div>
</UseFocusTrap>
</template>
</Dropdown>
<ProjectCreateModal

View File

@@ -2,11 +2,10 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { computed, ref, watch } from 'vue';
import { computed, ref } from 'vue';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import Pagination from '@/Components/Common/Pagination.vue';
export type SortColumn =
| 'name'
@@ -144,19 +143,6 @@ const sortedProjects = 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.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<Project | undefined> {
@@ -213,7 +199,7 @@ const gridTemplate = computed(() => {
>Create your First Project
</SecondaryButton>
</div>
<template v-for="project in paginatedProjects" :key="project.id">
<template v-for="project in sortedProjects" :key="project.id">
<ProjectTableRow
:show-billable-rate="props.showBillableRate"
:project="project"></ProjectTableRow>
@@ -221,8 +207,4 @@ const gridTemplate = computed(() => {
</div>
</div>
</div>
<Pagination
v-model:page="currentPage"
:total="sortedProjects.length"
:items-per-page="PAGE_SIZE"></Pagination>
</template>

View File

@@ -4,12 +4,15 @@ import AppLayout from '@/Layouts/AppLayout.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import {
ChartBarIcon,
ChevronLeftIcon,
ChevronDoubleLeftIcon,
ChevronRightIcon,
ChevronDoubleRightIcon,
ClockIcon,
EllipsisVerticalIcon,
ArrowDownTrayIcon,
LockClosedIcon,
} from '@heroicons/vue/20/solid';
import Pagination from '@/Components/Common/Pagination.vue';
import {
DropdownMenu,
DropdownMenuContent,
@@ -40,6 +43,16 @@ import { useClientsQuery } from '@/utils/useClientsQuery';
import { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money';
import { useMembersQuery } from '@/utils/useMembersQuery';
import {
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot,
} from 'radix-vue';
import { useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
@@ -397,6 +410,62 @@ async function downloadExport(format: ExportFormat) {
</div>
</div>
<Pagination v-model:page="currentPage" :total="totalPages" :items-per-page="pageLimit" />
<PaginationRoot
v-model:page="currentPage"
:total="totalPages"
:items-per-page="pageLimit"
class="flex justify-center items-center py-8"
:sibling-count="1"
show-edges>
<PaginationList v-slot="{ items }" class="flex items-center space-x-1 relative">
<div class="pr-2 flex items-center space-x-1 border-r border-border-primary mr-1">
<PaginationFirst class="navigation-item">
<ChevronDoubleLeftIcon class="w-4"> </ChevronDoubleLeftIcon>
</PaginationFirst>
<PaginationPrev class="mr-4 navigation-item">
<ChevronLeftIcon class="w-4 text-text-tertiary hover:text-text-primary">
</ChevronLeftIcon>
</PaginationPrev>
</div>
<template v-for="(page, index) in items">
<PaginationListItem
v-if="page.type === 'page'"
:key="index"
class="pagination-item"
:value="page.value">
{{ page.value }}
</PaginationListItem>
<PaginationEllipsis
v-else
:key="page.type"
:index="index"
class="PaginationEllipsis">
<div class="px-2">&#8230;</div>
</PaginationEllipsis>
</template>
<div class="!ml-2 pl-2 flex items-center space-x-1 border-l border-border-primary">
<PaginationNext class="navigation-item">
<ChevronRightIcon
class="w-4 text-text-tertiary hover:text-text-primary"></ChevronRightIcon>
</PaginationNext>
<PaginationLast class="navigation-item">
<ChevronDoubleRightIcon
class="w-4 text-text-tertiary hover:text-text-primary"></ChevronDoubleRightIcon>
</PaginationLast>
</div>
</PaginationList>
</PaginationRoot>
</AppLayout>
</template>
<style lang="postcss">
.navigation-item {
@apply bg-quaternary h-8 w-8 flex items-center justify-center rounded border border-border-primary text-text-tertiary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-ring;
}
.pagination-item {
@apply bg-secondary h-8 w-8 flex items-center justify-center rounded border border-border-tertiary text-text-secondary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-ring;
}
.pagination-item[data-selected] {
@apply text-text-primary bg-accent-300/10 border border-accent-300/20 rounded-md font-medium hover:bg-accent-300/20 active:bg-accent-300/20 outline-0 focus-visible:ring-2 focus:ring-ring transition ease-in-out duration-150;
}
</style>

View File

@@ -57,7 +57,6 @@
"@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",

View File

@@ -8,8 +8,8 @@ import {
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
ComboboxVirtualizer,
} from 'reka-ui';
} from 'radix-vue';
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { Check, Plus } from '@lucide/vue';
@@ -26,6 +26,10 @@ 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(() => {
@@ -54,23 +58,15 @@ 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) ?? NO_CLIENT;
return (
props.clients.find((client) => client.id === model.value) ?? {
id: null,
name: '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 }) {
@@ -85,51 +81,56 @@ function updateValue(client: { id: string | null; name: string }) {
<slot name="trigger"></slot>
</template>
<template #content>
<div v-if="open">
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<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">
<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>
<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>
</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>
</div>
</UseFocusTrap>
</template>
</Dropdown>
</template>

View File

@@ -9,16 +9,10 @@ import {
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
ComboboxVirtualizer,
} from 'reka-ui';
} from 'radix-vue';
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: [],
});
@@ -62,23 +56,6 @@ 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);
@@ -97,35 +74,46 @@ const emit = defineEmits(['update:modelValue', 'changed', 'submit']);
<slot name="trigger"></slot>
</template>
<template #content>
<ComboboxRoot v-model:open="open" class="p-2" :ignore-filter="true">
<ComboboxRoot
v-model:search-term="searchValue"
v-model:open="open"
class="p-2"
:filter-function="(val: string[]) => val">
<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">
<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>
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>
</ComboboxViewport>
</ComboboxContent>
</ComboboxRoot>

View File

@@ -9,9 +9,9 @@ defineProps<{
<template>
<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">
<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 shrink-0"></div>
<span class="truncate">{{ name }}</span>
<div class="flex space-x-3 items-center px-3 py-1.5">
<div :style="{ backgroundColor: color }" class="w-3 h-3 rounded-full"></div>
<span>{{ name }}</span>
</div>
<slot name="actions"></slot>
</div>

View File

@@ -87,32 +87,4 @@ describe('TimeTrackerProjectTaskDropdown', () => {
expect(wrapper.emitted('changed')?.at(-1)).toEqual(['', null]);
});
it("keeps a project's tasks visible when the search term matches the project name", async () => {
const project = {
id: 'p-dummy',
name: 'dummy',
color: '#fff',
client_id: null,
is_archived: false,
} as unknown as Project;
const tasks = [
{ id: 't-1', name: 'design', project_id: 'p-dummy', is_done: false },
{ id: 't-2', name: 'build', project_id: 'p-dummy', is_done: false },
] as unknown as Task[];
const wrapper = mountDropdown({ projects: [project], tasks });
await nextTick();
await nextTick();
const searchInput = wrapper.find('[data-testid="client_dropdown_search"]');
await searchInput.setValue('dummy');
await nextTick();
// project itself shows up
expect(wrapper.find('[data-project-id="p-dummy"]').exists()).toBe(true);
// and its tasks are still available even though they don't match "dummy":
// the task expander keeps showing all of the project's tasks
expect(wrapper.text()).toContain('2 Tasks');
});
});

View File

@@ -2,7 +2,6 @@
import { ChevronRightIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { computed, nextTick, ref, watch } from 'vue';
import { useVirtualizer } from '@tanstack/vue-virtual';
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
import type {
CreateClientBody,
@@ -30,6 +29,7 @@ const project = defineModel<string | null>('project', {
const searchInput = ref<HTMLInputElement | null>(null);
const open = ref(false);
const dropdownViewport = ref<HTMLElement | null>(null);
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
const searchValue = ref('');
@@ -85,119 +85,72 @@ const filteredProjects = computed<ProjectWithTasks[]>(() => {
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 ROW_HEIGHT = { client: 28, project: 36, task: 32 } as const;
const rowVirtualizer = useVirtualizer(
computed(() => ({
count: flatRows.value.length,
getScrollElement: () => dropdownViewport.value,
estimateSize: (index: number) => {
const row = flatRows.value[index];
if (row?.kind === 'client') return ROW_HEIGHT.client;
if (row?.kind === 'task') return ROW_HEIGHT.task;
return ROW_HEIGHT.project;
},
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]!,
}))
);
// 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(
tempFilteredClients: ClientsWithProjectsWithTasks,
groupIndexByKey: Map<string, number>,
project: Project,
filteredTasks: Task[],
expanded = false
) {
const client = project.client_id ? clientsById.value.get(project.client_id) : undefined;
const groupKey = client ? client.id : 'no_client';
const newProject: ProjectWithTasks = { ...project, expanded, tasks: filteredTasks };
// check if client already exists in filter array
const projectClientIndex = tempFilteredClients.findIndex(
(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;
}
const client = props.clients.find((client) => client.id === project.client_id);
groupIndexByKey.set(groupKey, tempFilteredClients.length);
if (client) {
tempFilteredClients.push({ ...client, projects: [newProject] });
} else {
tempFilteredClients.push({
id: 'no_client',
name: 'No Client',
color: 'var(--theme-color-icon-default)',
created_at: '',
updated_at: '',
value: '',
is_archived: false,
projects: [newProject],
if (projectClientIndex !== -1) {
// client already exists in filter array
tempFilteredClients[projectClientIndex]!.projects.push({
...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 {
// project has no client
const customNoClientId = 'no_client';
const noClientIndex = tempFilteredClients.findIndex(
(client) => client.id === customNoClientId
);
if (noClientIndex !== -1) {
// no client group already exists in filter array
tempFilteredClients[noClientIndex]!.projects.push({
...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,
},
],
});
}
}
}
@@ -233,50 +186,39 @@ function updateFilteredResults() {
});
}
const searchTerm = searchValue.value?.toLowerCase()?.trim() || '';
const groupIndexByKey = new Map<string, number>();
for (const filterProject of props.projects) {
const projectNameIncludesSearchTerm = filterProject.name.toLowerCase().includes(searchTerm);
const projectNameIncludesSearchTerm = filterProject.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
const clientName = filterProject.client_id
? clientsById.value.get(filterProject.client_id)?.name
: undefined;
const clientNameIncludesSearchTerm = clientName?.toLowerCase().includes(searchTerm);
const clientNameIncludesSearchTerm = props.clients
.find((client) => client.id === filterProject.client_id)
?.name.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
const projectTasks = tasksByProject.value.get(filterProject.id) ?? [];
// tasks that should be selectable regardless of the search term
// (open tasks, plus the currently selected one even if it's done)
const availableTasks = projectTasks.filter((filterTask) => {
return !filterTask.is_done || filterTask.id === task.value;
// check if one of the project tasks
const projectTasks = props.tasks.filter((task) => {
return task.project_id === filterProject.id;
});
const filteredTasks = availableTasks.filter((filterTask) => {
return filterTask.name.toLowerCase().includes(searchTerm);
const filteredTasks = projectTasks.filter((filterTask) => {
return (
filterTask.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '') &&
(!filterTask.is_done || filterTask.id === task.value)
);
});
if (
(projectNameIncludesSearchTerm || clientNameIncludesSearchTerm) &&
(!filterProject.is_archived || project.value === filterProject.id)
) {
// search term matches project (or client) name: show all the tasks
addProjectToFilterObject(
tempFilteredClients,
groupIndexByKey,
filterProject,
availableTasks,
false
);
// search term matches project name
addProjectToFilterObject(tempFilteredClients, filterProject, filteredTasks, false);
} else if (filteredTasks.length > 0 && !filterProject.is_archived) {
// search term matches task name
addProjectToFilterObject(
tempFilteredClients,
groupIndexByKey,
filterProject,
filteredTasks,
true
);
addProjectToFilterObject(tempFilteredClients, filterProject, filteredTasks, true);
}
}
@@ -468,18 +410,24 @@ function moveHighlightDown() {
const highlightedItemId = ref<string | null>(null);
watch(highlightedItemId, () => {
if (highlightedItemId.value === null) {
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)
const highlightedItem = dropdownViewport.value?.querySelector(
`[data-project-id="${highlightedItemId.value}"]`
);
if (index !== -1) {
rowVirtualizer.value.scrollToIndex(index, { align: 'auto' });
if (highlightedItem) {
highlightedItem.scrollIntoView({
block: 'nearest',
inline: 'nearest',
});
} else {
const highlightedTask = dropdownViewport.value?.querySelector(
`[data-task-id="${highlightedItemId.value}"]`
);
if (highlightedTask) {
highlightedTask.scrollIntoView({
block: 'nearest',
inline: 'nearest',
});
}
}
});
@@ -594,7 +542,7 @@ const showCreateProject = ref(false);
</slot>
</template>
<template #content>
<div v-if="open">
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<input
ref="searchInput"
:value="searchValue"
@@ -610,63 +558,62 @@ const showCreateProject = ref(false);
@keydown.left.prevent="collapseProject" />
<div
ref="dropdownViewport"
class="w-[400px] max-w-[calc(100vw-2rem)] max-h-[350px] overflow-y-scroll relative"
class="min-w-[350px] max-h-[350px] overflow-y-scroll relative"
@mousemove="mouseEnterHighlightActivated = true">
<div :style="{ height: `${totalSize}px`, width: '100%', position: 'relative' }">
<template v-for="client in filteredResults" :key="client.id">
<div
v-for="{ virtualRow, row } in visibleRows"
:key="row.key"
class="absolute left-0 top-0 w-full"
:style="{ transform: `translateY(${virtualRow.start}px)` }">
v-if="client.id !== 'no_project_no_client'"
class="w-full pb-1 pt-2 px-2 text-text-tertiary text-xs font-semibold flex space-x-1 items-center">
<span>
{{ client.name }}
</span>
</div>
<template
v-for="projectWithTasks in client.projects"
:key="projectWithTasks.id">
<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"
class="px-1 py-0.5 cursor-default"
:value="row.project.id"
:data-project-id="row.project.id"
@click="selectProject(row.project.id)">
:value="projectWithTasks.id"
:data-project-id="projectWithTasks.id"
@click="selectProject(projectWithTasks.id)">
<div
class="rounded-lg"
:class="{
'bg-card-background-active':
row.project.id === highlightedItemId,
projectWithTasks.id === highlightedItemId,
}">
<ProjectDropdownItem
class="hover:!bg-transparent"
:selected="isProjectSelected(row.project)"
:name="row.project.name"
:color="row.project.color"
@mouseenter="setHighlightItemId(row.project.id)">
:selected="isProjectSelected(projectWithTasks)"
:name="projectWithTasks.name"
:color="projectWithTasks.color"
@mouseenter="setHighlightItemId(projectWithTasks.id)">
<template #actions>
<button
v-if="row.project.tasks.length > 0"
v-if="projectWithTasks.tasks.length > 0"
tabindex="-1"
class="px-2 py-0.5 mr-2 relative transition items-center rounded flex space-x-0.5 text-xs shrink-0"
class="px-2 py-0.5 mr-2 relative transition items-center rounded flex space-x-0.5 text-xs"
:class="{
'bg-white/5 text-text-secondary':
row.project.expanded,
projectWithTasks.expanded,
'hover:bg-white/5 hover:text-text-secondary text-text-tertiary':
!row.project.expanded,
!projectWithTasks.expanded,
}"
@click.prevent.stop="
() => {
row.project.expanded =
!row.project.expanded;
projectWithTasks.expanded =
!projectWithTasks.expanded;
searchInput?.focus();
}
">
<span class="whitespace-nowrap"
>{{ row.project.tasks.length }} Tasks</span
<span
>{{ projectWithTasks.tasks.length }} Tasks</span
>
<ChevronDownIcon
:class="{
'transform rotate-180':
row.project.expanded,
projectWithTasks.expanded,
}"
class="w-4"></ChevronDownIcon>
</button>
@@ -674,23 +621,23 @@ const showCreateProject = ref(false);
</ProjectDropdownItem>
</div>
</div>
<div
v-else-if="row.kind === 'task'"
:data-task-id="row.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"
:class="
row.task.id === highlightedItemId
? 'bg-card-background-active'
: 'bg-quaternary'
"
@click="selectTask(row.task.id)"
@mouseenter="setHighlightItemId(row.task.id)">
<MinusIcon
class="w-3 h-3 text-text-quaternary shrink-0"></MinusIcon>
<span class="min-w-0 truncate">{{ row.task.name }}</span>
<div v-if="projectWithTasks.expanded" class="bg-quaternary">
<div
v-for="task in projectWithTasks.tasks"
:key="task.id"
:data-task-id="task.id"
:class="{
'bg-card-background-active': task.id === highlightedItemId,
}"
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)"
@mouseenter="setHighlightItemId(task.id)">
<MinusIcon class="w-3 h-3 text-text-quaternary"></MinusIcon>
<span>{{ task.name }}</span>
</div>
</div>
</div>
</div>
</template>
</template>
</div>
<div v-if="canCreateProject" class="hover:bg-card-background-active rounded-b-lg">
<button
@@ -704,7 +651,7 @@ const showCreateProject = ref(false);
<span>Create new Project</span>
</button>
</div>
</div>
</UseFocusTrap>
</template>
</Dropdown>
<ProjectCreateModal

View File

@@ -199,7 +199,7 @@ body {
--muted-foreground: var(--color-text-tertiary);
--accent: var(--color-bg-tertiary);
--accent-foreground: var(--color-text-primary);
--destructive: 0 72% 60%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
--input: var(--color-border-tertiary);

View File

@@ -18,13 +18,3 @@ window.getTimezoneSetting = vi.fn(() => 'UTC');
window.getWeekStartSetting = vi.fn(() => 'monday');
window.getNumberFormat = vi.fn(() => 'point');
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,
});

View File

@@ -1,4 +1,4 @@
import { useQuery, keepPreviousData } from '@tanstack/vue-query';
import { useQuery } from '@tanstack/vue-query';
import { api, type TimeEntryResponse } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { computed, type Ref, type ComputedRef, unref } from 'vue';
@@ -21,9 +21,6 @@ export function useTimeEntriesReportQuery(
},
queries: { ...unref(filterParams) },
}),
// Keep the previous page's data (incl. meta.total) while the next page loads, so
// pagination doesn't transiently see total=1 and clamp the page back to 1.
placeholderData: keepPreviousData,
staleTime: 1000 * 30, // 30 seconds
});
}

View File

@@ -1,3 +0,0 @@
"Project","Client","Status","Visibility","Billability","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Tracked (USD)","Estimated (USD)","Remaining (USD)","Overage (USD)","Progress(%)","Recurring estimate","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Cost Rate (USD)","Expenses (USD)","Billable expenses (USD)","Non-billable expenses (USD)","Additional fields","Project members","Project manager","Note"
"Active Project","Big Company","Active","Public","Yes","Task 1, Task 2","0.00","100.00","","","","","","","","","0.00","0.00","100.01","0.00","","0.00","0.00","0.00","","Constantin Graf","",""
"Archived Project","","Archived","Public","Yes","","0.00","","","","","","","","","","0.00","0.00","","0.00","","0.00","0.00","0.00","","Constantin Graf","",""
1 Project Client Status Visibility Billability Tasks Tracked (h) Estimated (h) Remaining (h) Overage (h) Tracked (USD) Estimated (USD) Remaining (USD) Overage (USD) Progress(%) Recurring estimate Billable (h) Non-billable (h) Billable Rate (USD) Amount (USD) Cost Rate (USD) Expenses (USD) Billable expenses (USD) Non-billable expenses (USD) Additional fields Project members Project manager Note
2 Active Project Big Company Active Public Yes Task 1, Task 2 0.00 100.00 0.00 0.00 100.01 0.00 0.00 0.00 0.00 Constantin Graf
3 Archived Project Archived Public Yes 0.00 0.00 0.00 0.00 0.00 0.00 0.00 Constantin Graf

View File

@@ -1,2 +0,0 @@
"Project","Client","Status","Visibility","Billability","Activities","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Tracked (USD)","Estimated (USD)","Remaining (USD)","Overage (USD)","Progress(%)","Recurring estimate","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Cost Rate (USD)","Expenses (USD)","Billable expenses (USD)","Non-billable expenses (USD)","Additional fields","Project members","Project manager","Note"
"Project With Activities","","Active","Public","Yes","Activity A, Activity B","0.00","","","","","","","","","","0.00","0.00","","0.00","","0.00","0.00","0.00","","","",""
1 Project Client Status Visibility Billability Activities Tracked (h) Estimated (h) Remaining (h) Overage (h) Tracked (USD) Estimated (USD) Remaining (USD) Overage (USD) Progress(%) Recurring estimate Billable (h) Non-billable (h) Billable Rate (USD) Amount (USD) Cost Rate (USD) Expenses (USD) Billable expenses (USD) Non-billable expenses (USD) Additional fields Project members Project manager Note
2 Project With Activities Active Public Yes Activity A, Activity B 0.00 0.00 0.00 0.00 0.00 0.00 0.00

View File

@@ -1,3 +0,0 @@
"Project","Client","Description","Activity","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","No","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","Yes","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
1 Project Client Description Activity User Group Email Tags Billable Start Date Start Time End Date End Time Duration (h) Duration (decimal) Billable Rate (USD) Billable Amount (USD)
2 Project without Client Peter Tester peter.test@email.test Development, Backend No 03/04/2024 10:23:52 AM 03/04/2024 10:23:52 AM 00:00:00 0.00 0.00 0.00
3 Project for Big Company Big Company Working hard Task 1 Peter Tester peter.test@email.test Yes 03/04/2024 10:23 AM 03/04/2024 11:23:01 AM 01:00:01 0.00 0.00 0.00

View File

@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Service\Import\Importers\ClockifyProjectsImporter;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\ImportException;
@@ -52,68 +50,4 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
// Assert
$this->checkTestScenarioProjectsOnlyAfterImport();
}
public function test_import_sets_archived_at_based_on_status_column(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_2.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$activeProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Active Project')->firstOrFail();
$this->assertNull($activeProject->archived_at);
$this->assertFalse($activeProject->is_archived);
$archivedProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Archived Project')->firstOrFail();
$this->assertNotNull($archivedProject->archived_at);
$this->assertTrue($archivedProject->is_archived);
}
public function test_import_supports_renamed_tasks_column(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
// Newer Clockify exports rename the "Task" column to "Tasks".
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_2.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$activeProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Active Project')->firstOrFail();
$this->assertEqualsCanonicalizing(
['Task 1', 'Task 2'],
Task::query()->where('project_id', $activeProject->id)->pluck('name')->all(),
);
}
public function test_import_supports_activities_column_alias_for_tasks(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
// Some Clockify exports name the tasks column "Activities".
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_3.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$project = Project::query()->where('organization_id', $organization->id)->where('name', 'Project With Activities')->firstOrFail();
$this->assertEqualsCanonicalizing(
['Activity A', 'Activity B'],
Task::query()->where('project_id', $project->id)->pluck('name')->all(),
);
}
}

View File

@@ -117,25 +117,6 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->clientsCreated);
}
public function test_import_supports_activity_column_alias_for_task(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
// Some Clockify exports name the task column "Activity".
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_5.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(1, $report->tasksCreated);
}
public function test_import_fails_if_month_in_date_is_bigger_than_12(): void
{
// Arrange