Skip to content

Commit 88c0c33

Browse files
committed
add project progress sorting and fix direction ui for number based
columns in the project table
1 parent 0fc3253 commit 88c0c33

File tree

8 files changed

+292
-122
lines changed

8 files changed

+292
-122
lines changed

e2e/projects.spec.ts

Lines changed: 167 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import type { Page } from '@playwright/test';
33
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
44
import { test } from '../playwright/fixtures';
55
import { formatCentsWithOrganizationDefaults } from './utils/money';
6-
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
76
import {
87
createProjectViaApi,
98
createPublicProjectViaApi,
109
createTaskViaApi,
10+
createClientViaApi,
11+
createTimeEntryViaApi,
12+
archiveProjectViaApi,
1113
updateOrganizationSettingViaApi,
1214
} from './utils/api';
1315

@@ -335,61 +337,179 @@ test('test that editing an existing billable project with default rate loads cor
335337
});
336338

337339
// Sorting tests
338-
test('test that sorting projects by name works', async ({ page }) => {
339-
await goToProjectsOverview(page);
340-
await clearProjectTableState(page);
341-
await page.reload();
342-
343-
// Wait for the table to load
344-
await expect(page.getByTestId('project_table')).toBeVisible();
345-
346-
// Get initial project names
347-
const getProjectNames = async () => {
348-
const rows = page
349-
.getByTestId('project_table')
350-
.locator('[data-testid="project_table"] > div')
351-
.filter({ hasNot: page.locator('.border-t') });
352-
const names: string[] = [];
353-
const count = await page.getByTestId('project_table').getByRole('row').count();
354-
for (let i = 0; i < count; i++) {
355-
const row = page.getByTestId('project_table').getByRole('row').nth(i);
356-
const nameCell = row.locator('div').first();
357-
const text = await nameCell.textContent();
358-
if (text) {
359-
names.push(text.trim());
360-
}
361-
}
362-
return names;
363-
};
364-
365-
// Click on Name header to sort ascending (default should already be ascending)
366-
const nameHeader = page.getByText('Name').first();
367-
await nameHeader.click();
368-
369-
// Wait for sort indicator to appear
370-
await expect(nameHeader.locator('svg')).toBeVisible();
340+
test('test that sorting projects by all columns works', async ({ page, ctx }) => {
341+
// Seed projects with distinct values for each sortable column
342+
const clientAlpha = await createClientViaApi(ctx, { name: 'Alpha Client' });
343+
const clientBeta = await createClientViaApi(ctx, { name: 'Beta Client' });
344+
345+
// Project A: client Alpha, low billable rate, has estimated time, active
346+
const projectA = await createProjectViaApi(ctx, {
347+
name: 'AAA Project',
348+
client_id: clientAlpha.id,
349+
is_billable: true,
350+
billable_rate: 5000,
351+
estimated_time: 36000, // 10h
352+
});
353+
// Add 1h of time entries (10% progress)
354+
await createTimeEntryViaApi(ctx, {
355+
duration: '1h',
356+
projectId: projectA.id,
357+
});
371358

372-
// Click again to sort descending
373-
await nameHeader.click();
359+
// Project B: client Beta, high billable rate, has estimated time, archived
360+
const projectB = await createProjectViaApi(ctx, {
361+
name: 'BBB Project',
362+
client_id: clientBeta.id,
363+
is_billable: true,
364+
billable_rate: 15000,
365+
estimated_time: 7200, // 2h
366+
});
367+
// Add 1h of time entries (50% progress)
368+
await createTimeEntryViaApi(ctx, {
369+
duration: '1h',
370+
projectId: projectB.id,
371+
});
372+
await archiveProjectViaApi(ctx, {
373+
...projectB,
374+
client_id: clientBeta.id,
375+
billable_rate: 15000,
376+
estimated_time: 7200,
377+
});
374378

375-
// Verify the sort indicator is still visible (showing descending)
376-
await expect(nameHeader.locator('svg')).toBeVisible();
377-
});
379+
// Project C: no client, medium billable rate, no estimated time, active
380+
const projectC = await createProjectViaApi(ctx, {
381+
name: 'CCC Project',
382+
is_billable: true,
383+
billable_rate: 10000,
384+
});
385+
// Add 3h of time entries
386+
await createTimeEntryViaApi(ctx, {
387+
duration: '3h',
388+
projectId: projectC.id,
389+
});
378390

379-
test('test that sorting projects by status works', async ({ page }) => {
380391
await goToProjectsOverview(page);
381392
await clearProjectTableState(page);
382393
await page.reload();
383-
384-
// Default is "all" so no filter needed - Wait for the table to load
385394
await expect(page.getByTestId('project_table')).toBeVisible();
395+
await expect(page.getByText('AAA Project')).toBeVisible();
396+
await expect(page.getByText('BBB Project')).toBeVisible();
397+
await expect(page.getByText('CCC Project')).toBeVisible();
398+
399+
// Helper to get the visual order of our seeded projects by reading
400+
// all row text in a single evaluate call (avoids locator timing issues)
401+
const seededNames = ['AAA Project', 'BBB Project', 'CCC Project'];
402+
const getOrder = async (): Promise<string[]> => {
403+
const allRowTexts = await page.evaluate(() => {
404+
const table = document.querySelector('[data-testid="project_table"]');
405+
if (!table) return [];
406+
const rows = table.querySelectorAll('[role="row"]');
407+
return Array.from(rows).map((row) => row.textContent ?? '');
408+
});
409+
const order: string[] = [];
410+
for (const text of allRowTexts) {
411+
const match = seededNames.find((name) => text.includes(name));
412+
if (match) order.push(match);
413+
}
414+
return order;
415+
};
386416

387-
// Click on Status header to sort
388-
const statusHeader = page.getByText('Status').first();
389-
await statusHeader.click();
417+
// Helper: click a column header and wait for sort to apply.
418+
// expectedFirstAmongSeeded = which of our 3 seeded projects should appear first
419+
const clickSortHeader = async (headerText: string, expectedFirstAmongSeeded: string) => {
420+
const header = page
421+
.locator('[data-testid="project_table"] .select-none', {
422+
hasText: headerText,
423+
})
424+
.first();
425+
await header.click();
426+
// Wait until the expected project appears before the others among our seeded set
427+
await page.waitForFunction(
428+
({ expected, names }) => {
429+
const table = document.querySelector('[data-testid="project_table"]');
430+
if (!table) return false;
431+
const rows = table.querySelectorAll('[role="row"]');
432+
let firstSeededIdx = -1;
433+
for (let i = 0; i < rows.length; i++) {
434+
const text = rows[i].textContent ?? '';
435+
if (names.some((n: string) => text.includes(n))) {
436+
firstSeededIdx = i;
437+
break;
438+
}
439+
}
440+
if (firstSeededIdx === -1) return false;
441+
return (rows[firstSeededIdx].textContent ?? '').includes(expected);
442+
},
443+
{ expected: expectedFirstAmongSeeded, names: seededNames },
444+
{ timeout: 5000 }
445+
);
446+
};
390447

391-
// Sort indicator should be visible
392-
await expect(statusHeader.locator('svg')).toBeVisible();
448+
// --- Sort by Name ---
449+
// Default is name asc (A-Z)
450+
let order = await getOrder();
451+
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']);
452+
453+
// Click to toggle to Z-A
454+
await clickSortHeader('Name', 'CCC Project');
455+
order = await getOrder();
456+
expect(order).toEqual(['CCC Project', 'BBB Project', 'AAA Project']);
457+
458+
// --- Sort by Client (text: first click = A-Z, no-client last) ---
459+
await clickSortHeader('Client', 'AAA Project');
460+
order = await getOrder();
461+
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // Alpha, Beta, No client
462+
463+
// Reverse: Z-A, no-client still last
464+
await clickSortHeader('Client', 'BBB Project');
465+
order = await getOrder();
466+
expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // Beta, Alpha, No client
467+
468+
// --- Sort by Total Time (numeric: first click = highest first) ---
469+
await clickSortHeader('Total Time', 'CCC Project');
470+
order = await getOrder();
471+
expect(order[0]).toBe('CCC Project'); // C=3h first, A and B tied at 1h
472+
473+
// Reverse: lowest first
474+
await clickSortHeader('Total Time', 'AAA Project');
475+
order = await getOrder();
476+
expect(order[2]).toBe('CCC Project'); // C=3h last
477+
478+
// --- Sort by Billable Rate (numeric: first click = highest first) ---
479+
await clickSortHeader('Billable Rate', 'BBB Project');
480+
order = await getOrder();
481+
expect(order).toEqual(['BBB Project', 'CCC Project', 'AAA Project']); // 15000, 10000, 5000
482+
483+
// Reverse: lowest first
484+
await clickSortHeader('Billable Rate', 'AAA Project');
485+
order = await getOrder();
486+
expect(order).toEqual(['AAA Project', 'CCC Project', 'BBB Project']); // 5000, 10000, 15000
487+
488+
// --- Sort by Progress (numeric: first click = highest first, no-estimate last) ---
489+
await clickSortHeader('Progress', 'BBB Project');
490+
order = await getOrder();
491+
expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // 50%, 10%, no estimate
492+
493+
// Reverse: lowest first, no-estimate still last
494+
await clickSortHeader('Progress', 'AAA Project');
495+
order = await getOrder();
496+
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // 10%, 50%, no estimate
497+
498+
// --- Sort by Status (first click = active first, archived last) ---
499+
await expect(async () => {
500+
await clickSortHeader('Status', 'AAA Project');
501+
order = await getOrder();
502+
expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('AAA Project'));
503+
expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('CCC Project'));
504+
}).toPass({ timeout: 5000 });
505+
506+
// Reverse: archived first
507+
await expect(async () => {
508+
await clickSortHeader('Status', 'BBB Project');
509+
order = await getOrder();
510+
expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('AAA Project'));
511+
expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('CCC Project'));
512+
}).toPass({ timeout: 5000 });
393513
});
394514

395515
// Filter tests
@@ -642,22 +762,6 @@ test('test that estimated time input displays formatted value after blur', async
642762
await expect(estimatedTimeInput).toHaveValue(/1h.*30/);
643763
});
644764

645-
// Create new project with new Client
646-
647-
// Create new project with existing Client
648-
649-
// Delete project via More Options
650-
651-
// Test that project task count is displayed correctly
652-
653-
// Edit Project Modal Test
654-
655-
// Add Project with billable rate
656-
657-
// Edit Project with billable rate
658-
659-
// Edit Project Member Billable Rate
660-
661765
test('test that editing a task name on the project detail page works', async ({ page, ctx }) => {
662766
const projectName = 'Task Edit Project ' + Math.floor(1 + Math.random() * 10000);
663767
const originalTaskName = 'Original Task ' + Math.floor(1 + Math.random() * 10000);

e2e/utils/api.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,37 @@ export async function createProjectViaApi(
201201
return body.data as { id: string; name: string; color: string; is_billable: boolean };
202202
}
203203

204+
export async function archiveProjectViaApi(
205+
ctx: TestContext,
206+
project: {
207+
id: string;
208+
name: string;
209+
color: string;
210+
is_billable: boolean;
211+
client_id?: string | null;
212+
billable_rate?: number | null;
213+
estimated_time?: number | null;
214+
}
215+
) {
216+
const response = await ctx.request.put(
217+
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects/${project.id}`,
218+
{
219+
data: {
220+
name: project.name,
221+
color: project.color,
222+
is_billable: project.is_billable,
223+
is_archived: true,
224+
client_id: project.client_id ?? null,
225+
billable_rate: project.billable_rate ?? null,
226+
estimated_time: project.estimated_time ?? null,
227+
},
228+
}
229+
);
230+
expect(response.status()).toBe(200);
231+
const body = await response.json();
232+
return body.data;
233+
}
234+
204235
export async function createBillableProjectViaApi(
205236
ctx: TestContext,
206237
data: { name: string; billable_rate?: number | null }

0 commit comments

Comments
 (0)