@@ -3,11 +3,13 @@ import type { Page } from '@playwright/test';
33import { PLAYWRIGHT_BASE_URL } from '../playwright/config' ;
44import { test } from '../playwright/fixtures' ;
55import { formatCentsWithOrganizationDefaults } from './utils/money' ;
6- import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money' ;
76import {
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 ( / 1 h .* 3 0 / ) ;
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-
661765test ( '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 ) ;
0 commit comments