From 0d3978a55d63eec587bd85a0a37c09bde5307701 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Mon, 2 Feb 2026 01:31:35 +0100 Subject: [PATCH] Add reporting e2e helpers and detailed tests --- e2e/members.spec.ts | 130 ++++- e2e/project-members.spec.ts | 4 +- e2e/reporting-detailed.spec.ts | 598 +++++++++++++++++++++++ e2e/reporting.spec.ts | 838 ++++++++++++++++++++++++++------- e2e/time.spec.ts | 141 +++++- e2e/utils/reporting.ts | 275 +++++++++++ 6 files changed, 1813 insertions(+), 173 deletions(-) create mode 100644 e2e/reporting-detailed.spec.ts create mode 100644 e2e/utils/reporting.ts diff --git a/e2e/members.spec.ts b/e2e/members.spec.ts index 70be0e7c..49661246 100644 --- a/e2e/members.spec.ts +++ b/e2e/members.spec.ts @@ -3,12 +3,16 @@ // TODO: Remove Invitation import { expect, test } from '../playwright/fixtures'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import type { Page } from '@playwright/test'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; -async function goToMembersPage(page) { +async function goToMembersPage(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/members'); } -async function openInviteMemberModal(page) { +async function openInviteMemberModal(page: Page) { await Promise.all([ page.getByRole('button', { name: 'Invite Member' }).click(), expect(page.getByPlaceholder('Member Email')).toBeVisible(), @@ -35,7 +39,7 @@ test('test that new employee can be invited', async ({ page }) => { await page.getByRole('button', { name: 'Employee' }).click(); await Promise.all([ page.getByRole('button', { name: 'Invite Member', exact: true }).click(), - await expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`), + expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`), ]); }); @@ -91,3 +95,123 @@ test('test that organization billable rate can be updated with all existing time ), ]); }); + +async function createPlaceholderMemberViaImport(page: Page, placeholderName: string) { + const placeholderEmail = `placeholder+${Math.floor(Math.random() * 100000)}@solidtime-import.test`; + const csvContent = [ + 'User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Tags', + `${placeholderName},${placeholderEmail},,,,Imported entry,No,2024-01-01,09:00:00,2024-01-01,10:00:00,`, + ].join('\n'); + + // Write CSV to a temp file for upload + const tmpDir = os.tmpdir(); + const tmpFile = path.join(tmpDir, `import-${Date.now()}.csv`); + fs.writeFileSync(tmpFile, csvContent); + + await page.goto(PLAYWRIGHT_BASE_URL + '/import'); + + // Select "Toggl Time Entries" import type + await page.locator('select#importType').selectOption({ label: 'Toggl Time Entries' }); + + // Upload the CSV file + await page.locator('input[type="file"]').setInputFiles(tmpFile); + + // Click Import and wait for success + await Promise.all([ + page.getByRole('button', { name: 'Import Data' }).click(), + page.waitForResponse( + (response) => response.url().includes('/import') && response.status() === 200 + ), + ]); + + // Close the result modal + await page.getByRole('button', { name: 'Close' }).click(); + + // Clean up temp file + fs.unlinkSync(tmpFile); +} + +test('test that changing member role updates the role in the member table', async ({ page }) => { + const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000); + + // Create a placeholder member via import + await createPlaceholderMemberViaImport(page, placeholderName); + + // Go to members page and verify placeholder exists with role "Placeholder" + await goToMembersPage(page); + const memberRow = page.getByRole('row').filter({ hasText: placeholderName }); + await expect(memberRow).toBeVisible(); + await expect(memberRow.getByText('Placeholder')).toBeVisible(); + + // Open the edit modal for the placeholder member + await memberRow.getByRole('button').click(); + await page.getByRole('menuitem').getByText('Edit').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible(); + + // Change role to Employee + const roleSelect = page.getByRole('dialog').getByRole('combobox').first(); + await roleSelect.click(); + await page.getByRole('option', { name: 'Employee' }).click(); + + // Submit the change and verify the API call succeeds + await Promise.all([ + page.getByRole('button', { name: 'Update Member' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/members/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + ]); + + // Verify dialog closed + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // Verify the role updated in the table + await expect(memberRow.getByText('Employee')).toBeVisible(); +}); + +test('test that merging a placeholder member works', async ({ page }) => { + const placeholderName = 'Merge Target ' + Math.floor(Math.random() * 10000); + + // Create a placeholder member via import + await createPlaceholderMemberViaImport(page, placeholderName); + + // Go to members page + await goToMembersPage(page); + await expect(page.getByText(placeholderName)).toBeVisible(); + + // Find the placeholder member row and open actions menu + const placeholderRow = page.getByRole('row').filter({ hasText: placeholderName }); + await placeholderRow.getByRole('button').click(); + + // Click Merge + await page.getByTestId('member_merge').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible(); + + // Select the current user (the owner) as merge target via MemberCombobox + const combobox = page.getByRole('dialog').getByRole('combobox'); + await combobox.click(); + + // Wait for dropdown options to load + const firstOption = page.getByRole('option').first(); + await expect(firstOption).toBeVisible({ timeout: 10000 }); + await firstOption.click(); + + // Submit merge + await Promise.all([ + page.getByRole('button', { name: 'Merge Member' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/member/') && response.url().includes('/merge-into') && response.ok() + ), + ]); + + // Wait for dialog to close after successful merge + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // Verify placeholder member is no longer in the members table + await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible(); +}); diff --git a/e2e/project-members.spec.ts b/e2e/project-members.spec.ts index 555112e9..20fe201f 100644 --- a/e2e/project-members.spec.ts +++ b/e2e/project-members.spec.ts @@ -32,8 +32,8 @@ test('test that updating project member billable rate works for existing time en await page.getByRole('button', { name: 'Add Member' }).click(); await expect(page.getByText('Add Project Member').first()).toBeVisible(); - await page.getByRole('button', { name: 'Select a member' }).click(); - await page.keyboard.press('Enter'); + await page.getByRole('combobox').filter({ hasText: 'Select a member' }).click(); + await page.getByRole('option').first().click(); await page.getByRole('button', { name: 'Add Project Member' }).click(); await page diff --git a/e2e/reporting-detailed.spec.ts b/e2e/reporting-detailed.spec.ts new file mode 100644 index 00000000..d10305b9 --- /dev/null +++ b/e2e/reporting-detailed.spec.ts @@ -0,0 +1,598 @@ +import { expect } from '@playwright/test'; +import { test } from '../playwright/fixtures'; +import { + goToReportingDetailed, + createProject, + createClient, + createProjectWithClient, + createTask, + createTimeEntryWithProject, + createTimeEntryWithProjectAndTask, + createTimeEntryWithTag, + createBareTimeEntry, + waitForDetailedReportingUpdate, +} from './utils/reporting'; + +// Each test registers a new user and creates test data, which needs more time +test.describe.configure({ timeout: 60000 }); + +// ────────────────────────────────────────────────── +// Basic Detailed View Tests +// ────────────────────────────────────────────────── + +test('test that detailed view shows time entries correctly', async ({ page }) => { + const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); + + // Go to detailed reporting view + await goToReportingDetailed(page); + + // Verify the time entry is shown with all details + await expect(page.getByText(projectName, { exact: true })).toBeVisible(); + await expect(page.locator('input[name="Duration"]')).toHaveValue('1h 00min'); + await expect(page.getByText('Entry for ' + projectName, { exact: true })).toBeVisible(); +}); + +test('test that updating duration in detailed view works correctly', async ({ page }) => { + const projectName = 'Duration Update Project ' + Math.floor(Math.random() * 10000); + const initialDuration = '1h'; + const updatedDuration = '2h 30min'; + + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, initialDuration); + + // Go to detailed reporting view + await goToReportingDetailed(page); + + // Find and update the duration + const durationInput = page.locator('input[name="Duration"]').first(); + await durationInput.click(); + await durationInput.fill(updatedDuration); + await Promise.all([ + durationInput.press('Enter'), + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 200 + ), + ]); + + // Verify the new duration is displayed + await expect(durationInput).toHaveValue(updatedDuration); +}); + +// ────────────────────────────────────────────────── +// Project Filter Tests +// ────────────────────────────────────────────────── + +test('test that project multiselect filters work on detailed reporting page', async ({ page }) => { + const project1 = 'DetailProj1 ' + Math.floor(Math.random() * 10000); + const project2 = 'DetailProj2 ' + Math.floor(Math.random() * 10000); + + await createProject(page, project1); + await createProject(page, project2); + await createTimeEntryWithProject(page, project1, '1h'); + await createTimeEntryWithProject(page, project2, '2h'); + + await goToReportingDetailed(page); + + // Wait for initial data load + await expect(page.getByText(`Entry for ${project1}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${project2}`)).toBeVisible(); + + // Open project multiselect and select project1 + await page.getByRole('button', { name: 'Projects' }).first().click(); + await page.getByRole('option').filter({ hasText: project1 }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + // Verify only project1 entry is shown + await expect(page.getByText(`Entry for ${project1}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${project2}`)).not.toBeVisible(); +}); + +// ────────────────────────────────────────────────── +// Client Filter Tests +// ────────────────────────────────────────────────── + +test('test that client multiselect filters work on detailed reporting page', async ({ page }) => { + const client1 = 'DetailClient1 ' + Math.floor(Math.random() * 10000); + const project1 = 'DetailClientProj1 ' + Math.floor(Math.random() * 10000); + const project2 = 'DetailClientProj2 ' + Math.floor(Math.random() * 10000); + + await createClient(page, client1); + await createProjectWithClient(page, project1, client1); + await createProject(page, project2); + await createTimeEntryWithProject(page, project1, '1h'); + await createTimeEntryWithProject(page, project2, '2h'); + + await goToReportingDetailed(page); + + await expect(page.getByText(`Entry for ${project1}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${project2}`)).toBeVisible(); + + // Filter by client1 + await page.getByRole('button', { name: 'Clients' }).first().click(); + await page.getByRole('option').filter({ hasText: client1 }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + // Only entries for project1 (with client1) should be visible + await expect(page.getByText(`Entry for ${project1}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${project2}`)).not.toBeVisible(); +}); + +// ────────────────────────────────────────────────── +// Task Filter Tests +// ────────────────────────────────────────────────── + +test('test that task multiselect dropdown filters reporting by task', async ({ page }) => { + const projectName = 'TaskFilterProj ' + Math.floor(Math.random() * 10000); + const task1 = 'TaskFilter1 ' + Math.floor(Math.random() * 10000); + const task2 = 'TaskFilter2 ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTask(page, projectName, task1); + await createTask(page, projectName, task2); + await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h'); + await createTimeEntryWithProjectAndTask(page, projectName, task2, '2h'); + + // Use the detailed view to verify task filtering (shows individual entries) + await goToReportingDetailed(page); + + await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${projectName} - ${task2}`)).toBeVisible(); + + // Open task multiselect dropdown + await page.getByRole('button', { name: 'Tasks' }).first().click(); + + // Verify both tasks appear + await expect(page.getByRole('option').filter({ hasText: task1 })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: task2 })).toBeVisible(); + + // Select task1 + await page.getByRole('option').filter({ hasText: task1 }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + // Verify badge shows count of 1 + await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible(); + + // Verify only task1 entry is shown + await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${projectName} - ${task2}`)).not.toBeVisible(); +}); + +test('test that selecting multiple tasks shows correct badge count', async ({ page }) => { + const projectName = 'MultiTaskProj ' + Math.floor(Math.random() * 10000); + const task1 = 'MultiTask1 ' + Math.floor(Math.random() * 10000); + const task2 = 'MultiTask2 ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTask(page, projectName, task1); + await createTask(page, projectName, task2); + await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h'); + await createTimeEntryWithProjectAndTask(page, projectName, task2, '2h'); + + // Use the detailed view to verify task filtering + await goToReportingDetailed(page); + + await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${projectName} - ${task2}`)).toBeVisible(); + + // Select both tasks + await page.getByRole('button', { name: 'Tasks' }).first().click(); + await page.getByRole('option').filter({ hasText: task1 }).click(); + await page.getByRole('option').filter({ hasText: task2 }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + // Verify badge shows count of 2 + await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('2')).toBeVisible(); + + // Verify both task entries are shown + await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${projectName} - ${task2}`)).toBeVisible(); +}); + +test('test that deselecting a task removes the filter', async ({ page }) => { + const projectName = 'TaskDeselectProj ' + Math.floor(Math.random() * 10000); + const task1 = 'TaskDeselect1 ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTask(page, projectName, task1); + await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h'); + + await goToReportingDetailed(page); + await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible(); + + // Select task + await page.getByRole('button', { name: 'Tasks' }).first().click(); + await page.getByRole('option').filter({ hasText: task1 }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible(); + + // Deselect task + await page.getByRole('button', { name: 'Tasks' }).first().click(); + await page.getByRole('option').filter({ hasText: task1 }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + await expect( + page.getByRole('button', { name: 'Tasks' }).first().getByText(/^\d+$/) + ).not.toBeVisible(); +}); + +// ────────────────────────────────────────────────── +// Member Filter Tests +// ────────────────────────────────────────────────── + +test('test that member multiselect filters work on detailed reporting page', async ({ page }) => { + const projectName = 'DetailMemberProj ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); + + await goToReportingDetailed(page); + await expect(page.getByText(`Entry for ${projectName}`)).toBeVisible(); + + // Filter by the current member + await page.getByRole('button', { name: 'Members' }).first().click(); + await page.getByRole('option').filter({ hasText: 'John Doe' }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + // Data should still be visible since all entries belong to this member + await expect(page.getByText(`Entry for ${projectName}`)).toBeVisible(); + + // Verify badge shows count of 1 + await expect( + page.getByRole('button', { name: 'Members' }).first().getByText('1') + ).toBeVisible(); +}); + +// ────────────────────────────────────────────────── +// Tag Filter Tests +// ────────────────────────────────────────────────── + +test('test that tag filter works on detailed reporting page', async ({ page }) => { + const tag1 = 'DetailTag1 ' + Math.floor(Math.random() * 10000); + const tag2 = 'DetailTag2 ' + Math.floor(Math.random() * 10000); + + await createTimeEntryWithTag(page, tag1, '1h'); + await createTimeEntryWithTag(page, tag2, '2h'); + + await goToReportingDetailed(page); + + await expect(page.getByText(`Entry with tag ${tag1}`)).toBeVisible(); + await expect(page.getByText(`Entry with tag ${tag2}`)).toBeVisible(); + + // Filter by tag1 + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByRole('option').filter({ hasText: tag1 }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + await expect(page.getByText(`Entry with tag ${tag1}`)).toBeVisible(); + await expect(page.getByText(`Entry with tag ${tag2}`)).not.toBeVisible(); +}); + +// ────────────────────────────────────────────────── +// Billable Filter Tests +// ────────────────────────────────────────────────── + +test('test that billable filter works on detailed reporting page', async ({ page }) => { + const projectName = 'DetailBillProj ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); + + await goToReportingDetailed(page); + await expect(page.getByText(`Entry for ${projectName}`)).toBeVisible(); + + // Filter by billable only + await page.getByRole('combobox').filter({ hasText: 'Billable' }).click(); + await Promise.all([ + page.getByRole('option', { name: 'Billable', exact: true }).click(), + waitForDetailedReportingUpdate(page), + ]); + + // Switch to Non Billable + await page.getByRole('combobox').filter({ hasText: 'Billable' }).click(); + await Promise.all([ + page.getByRole('option', { name: 'Non Billable', exact: true }).click(), + waitForDetailedReportingUpdate(page), + ]); + + // Switch back to Both + await page.getByRole('combobox').filter({ hasText: 'Non Billable' }).click(); + await Promise.all([ + page.getByRole('option', { name: 'Both' }).click(), + waitForDetailedReportingUpdate(page), + ]); +}); + +// ────────────────────────────────────────────────── +// Combined Filter Tests +// ────────────────────────────────────────────────── + +test('test that combining project and task filters narrows results', async ({ page }) => { + const projectName = 'CombinedProj ' + Math.floor(Math.random() * 10000); + const otherProject = 'OtherCombProj ' + Math.floor(Math.random() * 10000); + const task1 = 'CombinedTask1 ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createProject(page, otherProject); + await createTask(page, projectName, task1); + await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h'); + await createTimeEntryWithProject(page, otherProject, '2h'); + + await goToReportingDetailed(page); + + await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${otherProject}`)).toBeVisible(); + + // Filter by project + await page.getByRole('button', { name: 'Projects' }).first().click(); + await page.getByRole('option').filter({ hasText: projectName }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + // Additionally filter by task + await page.getByRole('button', { name: 'Tasks' }).first().click(); + await page.getByRole('option').filter({ hasText: task1 }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + // Verify both badges show count of 1 + await expect( + page.getByRole('button', { name: 'Projects' }).first().getByText('1') + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible(); + + // Verify only the combined entry is shown + await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${otherProject}`)).not.toBeVisible(); +}); + +test('test that combining client and member filters narrows results on detailed page', async ({ + page, +}) => { + const client1 = 'CombClient ' + Math.floor(Math.random() * 10000); + const project1 = 'CombClientProj ' + Math.floor(Math.random() * 10000); + const project2 = 'CombNoClientProj ' + Math.floor(Math.random() * 10000); + + await createClient(page, client1); + await createProjectWithClient(page, project1, client1); + await createProject(page, project2); + await createTimeEntryWithProject(page, project1, '1h'); + await createTimeEntryWithProject(page, project2, '2h'); + + await goToReportingDetailed(page); + + await expect(page.getByText(`Entry for ${project1}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${project2}`)).toBeVisible(); + + // Filter by client + await page.getByRole('button', { name: 'Clients' }).first().click(); + await page.getByRole('option').filter({ hasText: client1 }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + // Additionally filter by member + await page.getByRole('button', { name: 'Members' }).first().click(); + await page.getByRole('option').filter({ hasText: 'John Doe' }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + // Only project1 entry should be visible (filtered by client + member) + await expect(page.getByText(`Entry for ${project1}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${project2}`)).not.toBeVisible(); + + // Both badges should show count of 1 + await expect( + page.getByRole('button', { name: 'Clients' }).first().getByText('1') + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Members' }).first().getByText('1') + ).toBeVisible(); +}); + +test('test that combining tag and project filters narrows results', async ({ page }) => { + const tag1 = 'CombTag ' + Math.floor(Math.random() * 10000); + const project1 = 'CombTagProj ' + Math.floor(Math.random() * 10000); + + await createProject(page, project1); + + // Create a time entry with a project (no tag) + await createTimeEntryWithProject(page, project1, '1h'); + + // Create a time entry with a tag (no specific project) + await createTimeEntryWithTag(page, tag1, '2h'); + + await goToReportingDetailed(page); + + await expect(page.getByText(`Entry for ${project1}`)).toBeVisible(); + await expect(page.getByText(`Entry with tag ${tag1}`)).toBeVisible(); + + // Filter by project + await page.getByRole('button', { name: 'Projects' }).first().click(); + await page.getByRole('option').filter({ hasText: project1 }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + // Only the project entry should be visible (tagged entry has no project) + await expect(page.getByText(`Entry for ${project1}`)).toBeVisible(); + await expect(page.getByText(`Entry with tag ${tag1}`)).not.toBeVisible(); +}); + +// ────────────────────────────────────────────────── +// "No X" Filter Tests +// ────────────────────────────────────────────────── + +test('test that "No Project" filter shows entries without a project', async ({ page }) => { + const project1 = 'NoProj1 ' + Math.floor(Math.random() * 10000); + + await createProject(page, project1); + await createTimeEntryWithProject(page, project1, '1h'); + await createBareTimeEntry(page, 'Bare entry no project', '30min'); + + await goToReportingDetailed(page); + + await expect(page.getByText(`Entry for ${project1}`)).toBeVisible(); + await expect(page.getByText('Bare entry no project')).toBeVisible(); + + // Open project dropdown and select "No Project" + await page.getByRole('button', { name: 'Projects' }).first().click(); + await page.getByRole('option').filter({ hasText: 'No Project' }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + // Verify badge shows 1 + await expect( + page.getByRole('button', { name: 'Projects' }).first().getByText('1') + ).toBeVisible(); + + // Only the bare entry (no project) should be visible + await expect(page.getByText('Bare entry no project')).toBeVisible(); + await expect(page.getByText(`Entry for ${project1}`)).not.toBeVisible(); +}); + +test('test that "No Task" filter shows entries without a task', async ({ page }) => { + const projectName = 'NoTaskProj ' + Math.floor(Math.random() * 10000); + const task1 = 'NoTaskFilter1 ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTask(page, projectName, task1); + await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h'); + await createTimeEntryWithProject(page, projectName, '30min'); + + await goToReportingDetailed(page); + + await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible(); + + // Open task dropdown and select "No Task" + await page.getByRole('button', { name: 'Tasks' }).first().click(); + await page.getByRole('option').filter({ hasText: 'No Task' }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible(); + + // Only the entry without a task should be visible + await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).not.toBeVisible(); +}); + +test('test that "No Tag" filter shows entries without tags', async ({ page }) => { + const tag1 = 'NoTagFilter1 ' + Math.floor(Math.random() * 10000); + + await createTimeEntryWithTag(page, tag1, '1h'); + await createBareTimeEntry(page, 'Entry without any tag', '30min'); + + await goToReportingDetailed(page); + + await expect(page.getByText(`Entry with tag ${tag1}`)).toBeVisible(); + await expect(page.getByText('Entry without any tag')).toBeVisible(); + + // Open tag dropdown and select "No Tag" + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByRole('option').filter({ hasText: 'No Tag' }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible(); + + await expect(page.getByText('Entry without any tag')).toBeVisible(); + await expect(page.getByText(`Entry with tag ${tag1}`)).not.toBeVisible(); +}); + +test('test that "No Client" filter shows entries without a client', async ({ page }) => { + const client1 = 'NoClientFilter ' + Math.floor(Math.random() * 10000); + const projectWithClient = 'NoClientProj1 ' + Math.floor(Math.random() * 10000); + const projectNoClient = 'NoClientProj2 ' + Math.floor(Math.random() * 10000); + + await createClient(page, client1); + await createProjectWithClient(page, projectWithClient, client1); + await createProject(page, projectNoClient); + await createTimeEntryWithProject(page, projectWithClient, '1h'); + await createTimeEntryWithProject(page, projectNoClient, '30min'); + + await goToReportingDetailed(page); + + await expect(page.getByText(`Entry for ${projectWithClient}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${projectNoClient}`)).toBeVisible(); + + // Open client dropdown and select "No Client" + await page.getByRole('button', { name: 'Clients' }).first().click(); + await page.getByRole('option').filter({ hasText: 'No Client' }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + await expect( + page.getByRole('button', { name: 'Clients' }).first().getByText('1') + ).toBeVisible(); + + await expect(page.getByText(`Entry for ${projectNoClient}`)).toBeVisible(); + await expect(page.getByText(`Entry for ${projectWithClient}`)).not.toBeVisible(); +}); + +test('test that combining "No Project" with a project ID shows both', async ({ page }) => { + const project1 = 'CombNoProj ' + Math.floor(Math.random() * 10000); + + await createProject(page, project1); + await createTimeEntryWithProject(page, project1, '1h'); + await createBareTimeEntry(page, 'Bare combined entry', '30min'); + + await goToReportingDetailed(page); + + await expect(page.getByText(`Entry for ${project1}`)).toBeVisible(); + await expect(page.getByText('Bare combined entry')).toBeVisible(); + + // Select both "No Project" and the specific project + await page.getByRole('button', { name: 'Projects' }).first().click(); + await page.getByRole('option').filter({ hasText: 'No Project' }).click(); + await page.getByRole('option').filter({ hasText: project1 }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + // Badge should show 2 + await expect( + page.getByRole('button', { name: 'Projects' }).first().getByText('2') + ).toBeVisible(); + + // Both entries should be visible + await expect(page.getByText(`Entry for ${project1}`)).toBeVisible(); + await expect(page.getByText('Bare combined entry')).toBeVisible(); +}); + +// ────────────────────────────────────────────────── +// Keyboard Navigation Tests +// ────────────────────────────────────────────────── + +test('test that keyboard navigation works in multiselect dropdown', async ({ page }) => { + const project1 = 'KbNavProj1 ' + Math.floor(Math.random() * 10000); + const project2 = 'KbNavProj2 ' + Math.floor(Math.random() * 10000); + + await createProject(page, project1); + await createProject(page, project2); + await createTimeEntryWithProject(page, project1, '1h'); + await createTimeEntryWithProject(page, project2, '2h'); + + await goToReportingDetailed(page); + await expect(page.getByText(`Entry for ${project1}`)).toBeVisible(); + + // Open project dropdown + await page.getByRole('button', { name: 'Projects' }).first().click(); + + // The search input should be focused, first item ("No Project") highlighted + await expect(page.getByPlaceholder('Search for a Project...')).toBeFocused(); + + // Press ArrowDown to move to first project, then Enter to select it + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + + // Close dropdown and verify filter applied + await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]); + + // Badge should show 1 + await expect( + page.getByRole('button', { name: 'Projects' }).first().getByText('1') + ).toBeVisible(); +}); diff --git a/e2e/reporting.spec.ts b/e2e/reporting.spec.ts index 330816e6..02a1825a 100644 --- a/e2e/reporting.spec.ts +++ b/e2e/reporting.spec.ts @@ -1,149 +1,431 @@ import { expect } from '@playwright/test'; -import type { Page } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; +import { + goToReporting, + createProject, + createClient, + createProjectWithClient, + createTask, + createTimeEntryWithProject, + createTimeEntryWithProjectAndTask, + createTimeEntryWithTag, + createTimeEntryWithBillableStatus, + waitForReportingUpdate, +} from './utils/reporting'; -async function goToTimeOverview(page: Page) { - await page.goto(PLAYWRIGHT_BASE_URL + '/time'); -} +// Each test registers a new user and creates test data, which needs more time +test.describe.configure({ timeout: 60000 }); -async function goToReporting(page: Page) { - await page.goto(PLAYWRIGHT_BASE_URL + '/reporting'); -} +// ────────────────────────────────────────────────── +// No-op Dropdown Close Tests +// ────────────────────────────────────────────────── -async function goToReportingDetailed(page: Page) { - await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed'); -} +test('test that opening and closing a filter dropdown without changes does not trigger an API request', async ({ + page, +}) => { + const projectName = 'NoOpDropdown ' + Math.floor(Math.random() * 10000); -async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) { - // First create the project through the Projects page - await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); - await page.getByRole('button', { name: 'Create Project' }).click(); - await page.getByLabel('Project Name').fill(projectName); - await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(); + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); - // Wait for the project to be created and visible in the list - await page.getByText(projectName).waitFor({ state: 'visible' }); + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); - // Then create the time entry - await goToTimeOverview(page); - - // Open the dropdown menu and click "Manual time entry" - await page.getByRole('button', { name: 'Time entry actions' }).click(); - await page.getByRole('menuitem', { name: 'Manual time entry' }).click(); - - // Fill in the time entry details - await page - .getByRole('dialog') - .getByRole('textbox', { name: 'Description' }) - .fill(`Time entry for ${projectName}`); - - await page.getByRole('button', { name: 'No Project' }).click(); - await page.getByText(projectName).click(); - - // Set duration - await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); - await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); - - // Submit the time entry - await Promise.all([ - page.getByRole('button', { name: 'Create Time Entry' }).click(), - page.waitForResponse( - (response) => response.url().includes('/time-entries') && response.status() === 201 - ), - ]); -} - -async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) { - await goToTimeOverview(page); - - // Open the dropdown menu and click "Manual time entry" - await page.getByRole('button', { name: 'Time entry actions' }).click(); - await page.getByRole('menuitem', { name: 'Manual time entry' }).click(); - - // Fill in the time entry details - await page - .getByRole('dialog') - .getByRole('textbox', { name: 'Description' }) - .fill(`Time entry with tag ${tagName}`); - - // Add tag - await page.getByRole('button', { name: 'Tags' }).click(); - await page.getByText('Create new tag').click(); - await page.getByPlaceholder('Tag Name').fill(tagName); - await page.getByRole('button', { name: 'Create Tag' }).click(); + // Wait for initial reporting data to fully load and all network activity to settle + await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); await page.waitForLoadState('networkidle'); - // Set duration - await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); - await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + // Set up a request counter for aggregate API calls + let aggregateRequestCount = 0; + page.on('response', (response) => { + if ( + response.url().includes('/time-entries/aggregate') && + !response.url().includes('/export') && + response.status() === 200 + ) { + aggregateRequestCount++; + } + }); - // Submit the time entry - await page.getByRole('button', { name: 'Create Time Entry' }).click(); -} + // Open project dropdown, change nothing, close it + await page.getByRole('button', { name: 'Projects' }).first().click(); + await expect(page.getByPlaceholder('Search for a Project...')).toBeVisible(); + await page.keyboard.press('Escape'); -async function createTimeEntryWithBillableStatus( - page: Page, - isBillable: boolean, - duration: string -) { - await goToTimeOverview(page); + // Open member dropdown, change nothing, close it + await page.getByRole('button', { name: 'Members' }).first().click(); + await expect(page.getByPlaceholder('Search for a Member...')).toBeVisible(); + await page.keyboard.press('Escape'); - // Open the dropdown menu and click "Manual time entry" - await page.getByRole('button', { name: 'Time entry actions' }).click(); - await page.getByRole('menuitem', { name: 'Manual time entry' }).click(); + // Open client dropdown, change nothing, close it + await page.getByRole('button', { name: 'Clients' }).first().click(); + await expect(page.getByPlaceholder('Search for a Client...')).toBeVisible(); + await page.keyboard.press('Escape'); - // Fill in the time entry details - await page - .getByRole('dialog') - .getByRole('textbox', { name: 'Description' }) - .fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`); + // Wait for all network activity to settle before asserting no requests were made + await page.waitForLoadState('networkidle'); - // Set billable status - await page.getByRole('button', { name: 'Non-Billable' }).click(); - if (!isBillable) { - await page.getByRole('option', { name: 'Non Billable', exact: true }).click(); - } else { - await page.getByRole('option', { name: 'Billable', exact: true }).click(); - } + // No aggregate API requests should have been made + expect(aggregateRequestCount).toBe(0); - // Set duration - await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); - await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + // Verify the report data is still intact (no flash/reload) + await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); +}); - // Submit the time entry - await page.getByRole('button', { name: 'Create Time Entry' }).click(); -} +// ────────────────────────────────────────────────── +// Project Multiselect Dropdown Tests +// ────────────────────────────────────────────────── -test('test that project filtering works in reporting', async ({ page }) => { - const project1 = 'Test Project 1 ' + Math.floor(Math.random() * 10000); - const project2 = 'Test Project 2 ' + Math.floor(Math.random() * 10000); +test('test that project multiselect dropdown shows projects and filters reporting', async ({ + page, +}) => { + const project1 = 'ProjFilter1 ' + Math.floor(Math.random() * 10000); + const project2 = 'ProjFilter2 ' + Math.floor(Math.random() * 10000); - // Create time entries for both projects + await createProject(page, project1); + await createProject(page, project2); await createTimeEntryWithProject(page, project1, '1h'); await createTimeEntryWithProject(page, project2, '2h'); - // Go to reporting and filter by project1 await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Open project multiselect dropdown await page.getByRole('button', { name: 'Projects' }).first().click(); + + // Verify both projects appear as options + await expect(page.getByRole('option').filter({ hasText: project1 })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: project2 })).toBeVisible(); + + // Select project1 await page.getByRole('option').filter({ hasText: project1 }).click(); - await Promise.all([ - // escape - page.keyboard.press('Escape'), - // wait for API request to finish - page.waitForResponse( - (response) => - response.url().includes('/time-entries/aggregate') && response.status() === 200 - ), - ]); - await page.waitForLoadState('networkidle'); + // Close dropdown and wait for report update + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); - // Verify only project1 time entries are shown + // Verify filter badge shows count of 1 + await expect( + page.getByRole('button', { name: 'Projects' }).first().getByText('1') + ).toBeVisible(); + + // Verify only project1 data is shown await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible(); await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible(); }); +test('test that project multiselect search filters the option list', async ({ page }) => { + const project1 = 'SearchableAlpha ' + Math.floor(Math.random() * 10000); + const project2 = 'SearchableBeta ' + Math.floor(Math.random() * 10000); + + await createProject(page, project1); + await createProject(page, project2); + await createTimeEntryWithProject(page, project1, '1h'); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Open project multiselect dropdown + await page.getByRole('button', { name: 'Projects' }).first().click(); + + // Type in search + await page.getByPlaceholder('Search for a Project...').fill('Alpha'); + + // Verify only matching project is visible + await expect(page.getByRole('option').filter({ hasText: project1 })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: project2 })).not.toBeVisible(); + + await page.keyboard.press('Escape'); +}); + +test('test that selecting multiple projects shows correct badge count', async ({ page }) => { + const project1 = 'MultiProj1 ' + Math.floor(Math.random() * 10000); + const project2 = 'MultiProj2 ' + Math.floor(Math.random() * 10000); + + await createProject(page, project1); + await createProject(page, project2); + await createTimeEntryWithProject(page, project1, '1h'); + await createTimeEntryWithProject(page, project2, '2h'); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Open project dropdown and select both + await page.getByRole('button', { name: 'Projects' }).first().click(); + await page.getByRole('option').filter({ hasText: project1 }).click(); + await page.getByRole('option').filter({ hasText: project2 }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + + // Verify filter badge shows count of 2 + await expect( + page.getByRole('button', { name: 'Projects' }).first().getByText('2') + ).toBeVisible(); + + // Verify both projects are shown in the report + await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible(); + await expect(page.getByTestId('reporting_view').getByText(project2)).toBeVisible(); +}); + +test('test that deselecting a project removes the filter', async ({ page }) => { + const project1 = 'DeselectProj ' + Math.floor(Math.random() * 10000); + + await createProject(page, project1); + await createTimeEntryWithProject(page, project1, '1h'); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Select project + await page.getByRole('button', { name: 'Projects' }).first().click(); + await page.getByRole('option').filter({ hasText: project1 }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + + // Verify badge count is 1 + await expect( + page.getByRole('button', { name: 'Projects' }).first().getByText('1') + ).toBeVisible(); + + // Deselect project + await page.getByRole('button', { name: 'Projects' }).first().click(); + await page.getByRole('option').filter({ hasText: project1 }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + + // Verify badge count is gone (no count displayed when 0) + await expect( + page.getByRole('button', { name: 'Projects' }).first().getByText(/^\d+$/) + ).not.toBeVisible(); +}); + +// ────────────────────────────────────────────────── +// Client Multiselect Dropdown Tests +// ────────────────────────────────────────────────── + +test('test that client multiselect dropdown filters reporting by client', async ({ page }) => { + const client1 = 'ClientFilter1 ' + Math.floor(Math.random() * 10000); + const client2 = 'ClientFilter2 ' + Math.floor(Math.random() * 10000); + const project1 = 'ClientProj1 ' + Math.floor(Math.random() * 10000); + const project2 = 'ClientProj2 ' + Math.floor(Math.random() * 10000); + + await createClient(page, client1); + await createClient(page, client2); + await createProjectWithClient(page, project1, client1); + await createProjectWithClient(page, project2, client2); + await createTimeEntryWithProject(page, project1, '1h'); + await createTimeEntryWithProject(page, project2, '2h'); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Open client multiselect dropdown + await page.getByRole('button', { name: 'Clients' }).first().click(); + + // Verify both clients appear + await expect(page.getByRole('option').filter({ hasText: client1 })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: client2 })).toBeVisible(); + + // Select client1 + await page.getByRole('option').filter({ hasText: client1 }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + + // Verify badge shows count of 1 + await expect( + page.getByRole('button', { name: 'Clients' }).first().getByText('1') + ).toBeVisible(); + + // Verify only project1 (belonging to client1) is shown + await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible(); + await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible(); +}); + +test('test that client multiselect search filters the option list', async ({ page }) => { + const client1 = 'ClientSearchAlpha ' + Math.floor(Math.random() * 10000); + const client2 = 'ClientSearchBeta ' + Math.floor(Math.random() * 10000); + + await createClient(page, client1); + await createClient(page, client2); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + await page.getByRole('button', { name: 'Clients' }).first().click(); + + // Search for "Alpha" + await page.getByPlaceholder('Search for a Client...').fill('Alpha'); + + await expect(page.getByRole('option').filter({ hasText: client1 })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: client2 })).not.toBeVisible(); + + await page.keyboard.press('Escape'); +}); + +test('test that deselecting a client removes the filter', async ({ page }) => { + const client1 = 'ClientDeselect ' + Math.floor(Math.random() * 10000); + const project1 = 'ClientDeselectProj ' + Math.floor(Math.random() * 10000); + + await createClient(page, client1); + await createProjectWithClient(page, project1, client1); + await createTimeEntryWithProject(page, project1, '1h'); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Select client + await page.getByRole('button', { name: 'Clients' }).first().click(); + await page.getByRole('option').filter({ hasText: client1 }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + + await expect( + page.getByRole('button', { name: 'Clients' }).first().getByText('1') + ).toBeVisible(); + + // Deselect client + await page.getByRole('button', { name: 'Clients' }).first().click(); + await page.getByRole('option').filter({ hasText: client1 }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + + await expect( + page.getByRole('button', { name: 'Clients' }).first().getByText(/^\d+$/) + ).not.toBeVisible(); +}); + +// ────────────────────────────────────────────────── +// Task Multiselect Dropdown Tests +// ────────────────────────────────────────────────── + +test('test that task filtering works in reporting', async ({ page }) => { + const projectName = 'Task Filter Proj ' + Math.floor(Math.random() * 10000); + const task1 = 'Task Filter A ' + Math.floor(Math.random() * 10000); + const task2 = 'Task Filter B ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '30min'); + await createTask(page, projectName, task1); + await createTask(page, projectName, task2); + await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h'); + await createTimeEntryWithProjectAndTask(page, projectName, task2, '2h'); + + // Go to reporting and group by task to see individual tasks + await goToReporting(page); + + // Filter by task1 + await page.getByRole('button', { name: 'Tasks' }).first().click(); + await page.getByRole('option').filter({ hasText: task1 }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + + // Verify the report only shows 1h (task1's duration) + await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible(); +}); + +test('test that task multiselect search filters the option list', async ({ page }) => { + const projectName = 'TaskSearchProj ' + Math.floor(Math.random() * 10000); + const task1 = 'TaskSearchAlpha ' + Math.floor(Math.random() * 10000); + const task2 = 'TaskSearchBeta ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTask(page, projectName, task1); + await createTask(page, projectName, task2); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + await page.getByRole('button', { name: 'Tasks' }).first().click(); + + await page.getByPlaceholder('Search for a Task...').fill('Alpha'); + + await expect(page.getByRole('option').filter({ hasText: task1 })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: task2 })).not.toBeVisible(); + + await page.keyboard.press('Escape'); +}); + +// ────────────────────────────────────────────────── +// Member Multiselect Dropdown Tests +// ────────────────────────────────────────────────── + +test('test that member multiselect dropdown shows current member and filters reporting', async ({ + page, +}) => { + const projectName = 'MemberFilterProj ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Open member multiselect dropdown + await page.getByRole('button', { name: 'Members' }).first().click(); + + // Verify the current user (John Doe from fixture) appears as an option + await expect(page.getByRole('option').filter({ hasText: 'John Doe' })).toBeVisible(); + + // Select the member + await page.getByRole('option').filter({ hasText: 'John Doe' }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + + // Verify badge shows count of 1 + await expect( + page.getByRole('button', { name: 'Members' }).first().getByText('1') + ).toBeVisible(); + + // Verify data is still shown (since all entries belong to this member) + await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); +}); + +test('test that member multiselect search filters the option list', async ({ page }) => { + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + await page.getByRole('button', { name: 'Members' }).first().click(); + + // Search for the registered user + await page.getByPlaceholder('Search for a Member...').fill('John'); + await expect(page.getByRole('option').filter({ hasText: 'John Doe' })).toBeVisible(); + + // Search for a non-existent member + await page.getByPlaceholder('Search for a Member...').fill('NonExistentMember'); + await expect(page.getByRole('option')).not.toBeVisible(); + + await page.keyboard.press('Escape'); +}); + +test('test that deselecting a member removes the filter', async ({ page }) => { + const projectName = 'MemberDeselectProj ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Select member + await page.getByRole('button', { name: 'Members' }).first().click(); + await page.getByRole('option').filter({ hasText: 'John Doe' }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + + await expect( + page.getByRole('button', { name: 'Members' }).first().getByText('1') + ).toBeVisible(); + + // Deselect member + await page.getByRole('button', { name: 'Members' }).first().click(); + await page.getByRole('option').filter({ hasText: 'John Doe' }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + + // Verify badge count is gone + await expect( + page.getByRole('button', { name: 'Members' }).first().getByText(/^\d+$/) + ).not.toBeVisible(); +}); + +// ────────────────────────────────────────────────── +// Tag Dropdown Tests +// ────────────────────────────────────────────────── + test('test that tag filtering works in reporting', async ({ page }) => { const tag1 = 'Test Tag 1 ' + Math.floor(Math.random() * 10000); const tag2 = 'Test Tag 2 ' + Math.floor(Math.random() * 10000); @@ -154,26 +436,111 @@ test('test that tag filtering works in reporting', async ({ page }) => { // Go to reporting and filter by tag1 await goToReporting(page); - // wait for all requests to finish - await page.waitForLoadState('networkidle'); + await expect(page.getByRole('button', { name: 'Tags' })).toBeVisible(); await page.getByRole('button', { name: 'Tags' }).click(); await page.getByText(tag1).click(); - await Promise.all([ - // escape - page.keyboard.press('Escape'), - // wait for API request to finish - page.waitForResponse( - (response) => - response.url().includes('/time-entries/aggregate') && response.status() === 200 - ), - ]); + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); // Verify only time entries with tag1 are shown await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible(); }); +test('test that tag dropdown search filters the option list', async ({ page }) => { + const tag1 = 'TagSearchAlpha ' + Math.floor(Math.random() * 10000); + const tag2 = 'TagSearchBeta ' + Math.floor(Math.random() * 10000); + + await createTimeEntryWithTag(page, tag1, '1h'); + await createTimeEntryWithTag(page, tag2, '2h'); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + await page.getByRole('button', { name: 'Tags' }).click(); + + await page.getByPlaceholder('Search for a Tag...').fill('Alpha'); + + await expect(page.getByRole('option').filter({ hasText: tag1 })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: tag2 })).not.toBeVisible(); + + await page.keyboard.press('Escape'); +}); + +test('test that selecting multiple tags shows correct badge count', async ({ page }) => { + const tag1 = 'MultiTag1 ' + Math.floor(Math.random() * 10000); + const tag2 = 'MultiTag2 ' + Math.floor(Math.random() * 10000); + + await createTimeEntryWithTag(page, tag1, '1h'); + await createTimeEntryWithTag(page, tag2, '2h'); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Select both tags + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByRole('option').filter({ hasText: tag1 }).click(); + await page.getByRole('option').filter({ hasText: tag2 }).click(); + + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + + // Verify badge shows count of 2 + await expect(page.getByRole('button', { name: 'Tags' }).getByText('2')).toBeVisible(); +}); + +test('test that deselecting a tag removes the filter', async ({ page }) => { + const tag1 = 'TagDeselect ' + Math.floor(Math.random() * 10000); + + await createTimeEntryWithTag(page, tag1, '1h'); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Select tag + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByRole('option').filter({ hasText: tag1 }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + + await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible(); + + // Deselect tag + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByRole('option').filter({ hasText: tag1 }).click(); + await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + + await expect(page.getByRole('button', { name: 'Tags' }).getByText(/^\d+$/)).not.toBeVisible(); +}); + +test('test that creating a tag inline from the reporting filter works', async ({ page }) => { + const projectName = 'TagCreateProj ' + Math.floor(Math.random() * 10000); + const newTag = 'InlineTag ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Open tag dropdown and create a new tag + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByText('Create new tag').click(); + await page.getByPlaceholder('Tag Name').fill(newTag); + + await Promise.all([ + page.getByRole('button', { name: 'Create Tag' }).click(), + page.waitForResponse( + (response) => response.url().includes('/tags') && response.status() === 201 + ), + ]); + + // The new tag should now be selected in the dropdown (badge should show 1) + await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible(); +}); + +// ────────────────────────────────────────────────── +// Billable Select Tests +// ────────────────────────────────────────────────── + test('test that billable status filtering works in reporting', async ({ page }) => { // Create billable and non-billable time entries await createTimeEntryWithBillableStatus(page, true, '1h'); @@ -182,60 +549,205 @@ test('test that billable status filtering works in reporting', async ({ page }) // Go to reporting and filter by billable await goToReporting(page); - await page.getByRole('button', { name: 'Billable' }).click(); - await page.getByRole('option', { name: 'Billable', exact: true }).click(); - + await page.getByRole('combobox').filter({ hasText: 'Billable' }).click(); await Promise.all([ - // escape - page.keyboard.press('Escape'), - // wait for API request to finish - page.waitForResponse( - (response) => - response.url().includes('/time-entries/aggregate') && response.status() === 200 - ), + page.getByRole('option', { name: 'Billable', exact: true }).click(), + waitForReportingUpdate(page), ]); - await page.waitForLoadState('networkidle'); await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible(); }); -test('test that detailed view shows time entries correctly', async ({ page }) => { - const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000); +test('test that billable filter can switch between all three states', async ({ page }) => { + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); - // Create a time entry + const billableSelect = page.getByRole('combobox').filter({ hasText: 'Billable' }); + + // Switch to Billable + await billableSelect.click(); + await Promise.all([ + page.getByRole('option', { name: 'Billable', exact: true }).click(), + waitForReportingUpdate(page), + ]); + + // Switch to Non Billable + await billableSelect.click(); + await Promise.all([ + page.getByRole('option', { name: 'Non Billable', exact: true }).click(), + waitForReportingUpdate(page), + ]); + + // Verify "Non Billable" is displayed + await expect(billableSelect).toContainText('Non Billable'); + + // Switch back to Both (cached by TanStack Query, no new API request) + await billableSelect.click(); + await page.getByRole('option', { name: 'Both' }).click(); + await expect(billableSelect).toContainText('Billable'); +}); + +// ────────────────────────────────────────────────── +// Rounding Controls Tests +// ────────────────────────────────────────────────── + +test('test that rounding can be enabled', async ({ page }) => { + const projectName = 'RoundingProj ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h 7min'); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Verify rounding is off by default + await expect(page.getByRole('button', { name: /Rounding off/ })).toBeVisible(); + + // Open rounding controls and enable rounding + await page.getByRole('button', { name: /Rounding off/ }).click(); + + const reportUpdatePromise = waitForReportingUpdate(page); + await page.getByRole('switch', { name: 'Enable Rounding' }).click(); + await reportUpdatePromise; + + // Close the popover by clicking elsewhere + await page.keyboard.press('Escape'); + + // Verify button text changed to "on" + await expect(page.getByRole('button', { name: /Rounding on/ })).toBeVisible(); +}); + +// ────────────────────────────────────────────────── +// Export Tests +// ────────────────────────────────────────────────── + +test('test that export dropdown shows all format options', async ({ page }) => { + const projectName = 'Export Test ' + Math.floor(Math.random() * 10000); + await createProject(page, projectName); await createTimeEntryWithProject(page, projectName, '1h'); - // Go to detailed reporting view - await goToReportingDetailed(page); + // Go to reporting page + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); - // Verify the time entry is shown with all details - await expect(page.getByText(projectName, { exact: true })).toBeVisible(); - await expect(page.locator('input[name="Duration"]')).toHaveValue('1h 00min'); - await expect(page.getByText('Time entry for ' + projectName, { exact: true })).toBeVisible(); + // Click the export button + await page.getByRole('button', { name: 'Export' }).click(); + + // Verify all 4 format options are visible + await expect(page.getByRole('menuitem', { name: /Export as PDF/i })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: /Export as Excel/i })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: /Export as CSV/i })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: /Export as ODS/i })).toBeVisible(); }); -test('test that updating duration in detailed view works correctly', async ({ page }) => { - const projectName = 'Duration Update Project ' + Math.floor(Math.random() * 10000); - const initialDuration = '1h'; - const updatedDuration = '2h 30min'; +test('test that CSV export triggers download', async ({ page }) => { + const projectName = 'CSV Export ' + Math.floor(Math.random() * 10000); + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); - // Create a time entry with initial duration - await createTimeEntryWithProject(page, projectName, initialDuration); + // Go to reporting page + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); - // Go to detailed reporting view - await goToReportingDetailed(page); + // Click export and select CSV, wait for the export API response with a download URL + await page.getByRole('button', { name: 'Export' }).click(); + const [exportResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate/export') && + response.status() === 200 + ), + page.getByRole('menuitem', { name: /Export as CSV/i }).click(), + ]); - // Find and update the duration - const durationInput = page.locator('input[name="Duration"]').first(); - await durationInput.click(); - await durationInput.fill(updatedDuration); - await durationInput.press('Enter'); + // Verify the API returned a download URL + const responseBody = await exportResponse.json(); + expect(responseBody.download_url).toBeTruthy(); - // Wait for the update to be processed - await page.waitForLoadState('networkidle'); + // Verify the export success modal appeared + await expect(page.getByText('Export Successful!')).toBeVisible(); - // Verify the new duration is displayed - await expect(durationInput).toHaveValue(updatedDuration); + // Verify the download URL is accessible and returns CSV content + const downloadResponse = await page.request.get(responseBody.download_url); + expect(downloadResponse.ok()).toBeTruthy(); + const contentType = downloadResponse.headers()['content-type']; + expect(contentType).toContain('csv'); }); -// TODO: test that date range filtering works in reporting +// ────────────────────────────────────────────────── +// Group By Tests +// ────────────────────────────────────────────────── + +test('test that group by select changes report grouping', async ({ page }) => { + const projectName = 'GroupBy Test ' + Math.floor(Math.random() * 10000); + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); + + // Go to reporting page + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Find the "Group by" selects within the reporting table + const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox'); + + // Click the first group by select to change grouping + await groupBySelects.filter({ hasText: 'Project' }).first().click(); + + // Select "Members" option and wait for the table query to update (has sub_group param) + const [aggregateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && + response.url().includes('sub_group') && + response.status() === 200 + ), + page.getByRole('option', { name: 'Members' }).click(), + ]); + + // Verify the API request contains the correct group parameter + const requestUrl = new URL(aggregateResponse.url()); + expect(requestUrl.searchParams.get('group')).toBe('user'); + + // Verify the grouping changed (the select should now show "Members") + await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible(); +}); + +test('test that setting group by to current sub group triggers sub group fallback', async ({ + page, +}) => { + const projectName = 'Fallback Test ' + Math.floor(Math.random() * 10000); + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); + + // Go to reporting page + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + // Find the "Group by" selects within the reporting table + const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox'); + + // Default state: group=Project, subGroup=Tasks + // Change group to "Tasks" (which is the current sub group) + await groupBySelects.filter({ hasText: 'Projects' }).first().click(); + + const [aggregateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && + response.url().includes('sub_group') && + response.status() === 200 + ), + page.getByRole('option', { name: 'Tasks' }).click(), + ]); + + // Verify the API request has group=task and sub_group changed away from task + const requestUrl = new URL(aggregateResponse.url()); + expect(requestUrl.searchParams.get('group')).toBe('task'); + expect(requestUrl.searchParams.get('sub_group')).not.toBe('task'); + + // The group should now be "Tasks" + await expect(groupBySelects.filter({ hasText: 'Tasks' }).first()).toBeVisible(); + + // The sub group should have fallen back to a different value (not "Tasks") + await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible(); +}); diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts index 8764d0d3..a5ab5443 100644 --- a/e2e/time.spec.ts +++ b/e2e/time.spec.ts @@ -53,7 +53,7 @@ test('test that starting and stopping an empty time entry shows a new time entry // Test that description update works async function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) { - await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(/bg-tertiary/); + await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(/bg-quaternary/); } test('test that updating a description of a time entry in the overview works on blur', async ({ @@ -224,7 +224,7 @@ test('test that starting a time entry from the overview works', async ({ page }) const newTimeEntry = timeEntryRows.first(); const startButton = newTimeEntry.getByTestId('timer_button'); - await expect(startButton).toHaveClass(/bg-tertiary/); + await expect(startButton).toHaveClass(/bg-quaternary/); await Promise.all([ page.waitForResponse(async (response) => { @@ -307,12 +307,143 @@ test.skip('test that load more works when the end of page is reached', async ({ // TODO: Test that time entries are loaded at the end of the page -// TODO: Test manual time entries - // TODO: Test Grouped time entries by description/project // TODO: Add Test for Date Update // TODO: Test that project can be created in the time entry row -// TODO: Add Tests for Mass Update +test('test that editing billable status via the edit modal works', async ({ page }) => { + await goToTimeOverview(page); + await createEmptyTimeEntry(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + const newTimeEntry = timeEntryRows.first(); + await assertThatTimeEntryRowIsStopped(newTimeEntry); + + // Open edit modal via the actions dropdown + const actionsDropdown = newTimeEntry + .getByRole('button', { name: 'Actions for the time entry' }) + .first(); + await actionsDropdown.click(); + await page.getByTestId('time_entry_edit').click(); + + // Verify the edit dialog is visible + await expect(page.getByRole('dialog')).toBeVisible(); + + // Change billable status to Billable + await page + .getByRole('dialog') + .getByRole('combobox') + .filter({ hasText: 'Non-Billable' }) + .click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + + // Save the time entry and verify the response has billable=true + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const updateBody = await updateResponse.json(); + expect(updateBody.data.billable).toBe(true); + + // Verify the dialog closed + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // Re-open the edit modal and verify it now shows "Billable" + await actionsDropdown.click(); + await page.getByTestId('time_entry_edit').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) + ).toBeVisible(); +}); + +test('test that mass update billable status works', async ({ page }) => { + await goToTimeOverview(page); + await createEmptyTimeEntry(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + await assertThatTimeEntryRowIsStopped(timeEntryRows.first()); + + // Select the time entry via the "Select All" checkbox + await page.getByLabel('Select All').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + // Open mass update modal via the Edit button in the mass action row + await page.getByRole('button', { name: 'Edit' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Change billable status to Billable + await page + .getByRole('dialog') + .getByRole('combobox') + .filter({ hasText: 'Set billable status' }) + .click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + + // Submit the mass update + const [massUpdateResponse] = await Promise.all([ + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entries' }).click(), + ]); + const massUpdateBody = await massUpdateResponse.json(); + expect(massUpdateBody.data.billable).toBe(true); + + // Verify dialog closes + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // Verify the UI reflects the billable status by re-opening the edit modal + const actionsDropdown = page + .locator('[data-testid="time_entry_row"]') + .first() + .getByRole('button', { name: 'Actions' }); + await actionsDropdown.click(); + await page.getByTestId('time_entry_edit').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect( + page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) + ).toBeVisible(); +}); + +test('test that setting billable status via the create modal works', async ({ page }) => { + await goToTimeOverview(page); + + // Open the dropdown menu and click "Manual time entry" + await page.getByRole('button', { name: 'Time entry actions' }).click(); + await page.getByRole('menuitem', { name: 'Manual time entry' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Set description + await page + .getByRole('dialog') + .getByRole('textbox', { name: 'Description' }) + .fill('Billable create test'); + + // Change billable status to Billable + await page + .getByRole('dialog') + .getByRole('combobox') + .filter({ hasText: 'Non-Billable' }) + .click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + + // Set duration + await page.locator('[role="dialog"] input[name="Duration"]').fill('1h'); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + + // Submit and verify the time entry was created with billable=true + await Promise.all([ + page.getByRole('button', { name: 'Create Time Entry' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/time-entries') && + response.status() === 201 && + (await response.json()).data.billable === true + ), + ]); +}); diff --git a/e2e/utils/reporting.ts b/e2e/utils/reporting.ts new file mode 100644 index 00000000..d7b63421 --- /dev/null +++ b/e2e/utils/reporting.ts @@ -0,0 +1,275 @@ +import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { PLAYWRIGHT_BASE_URL } from '../../playwright/config'; + +// ────────────────────────────────────────────────── +// Navigation +// ────────────────────────────────────────────────── + +export async function goToReporting(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/reporting'); +} + +export async function goToReportingDetailed(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed'); +} + +// ────────────────────────────────────────────────── +// Entity creation +// ────────────────────────────────────────────────── + +export async function createProject(page: Page, projectName: string) { + await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await expect(page.getByRole('button', { name: 'Create Project' })).toBeVisible(); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project name').fill(projectName); + await Promise.all([ + page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/projects') && + response.request().method() === 'POST' && + response.status() === 201 + ), + ]); + await expect(page.getByText(projectName)).toBeVisible(); +} + +export async function createClient(page: Page, clientName: string) { + await page.goto(PLAYWRIGHT_BASE_URL + '/clients'); + await expect(page.getByRole('button', { name: 'Create Client' })).toBeVisible(); + await page.getByRole('button', { name: 'Create Client' }).click(); + await page.getByPlaceholder('Client Name').fill(clientName); + await Promise.all([ + page.getByRole('button', { name: 'Create Client' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/clients') && + response.request().method() === 'POST' && + response.status() === 201 + ), + ]); + await expect(page.getByText(clientName)).toBeVisible(); +} + +export async function createProjectWithClient( + page: Page, + projectName: string, + clientName: string +) { + await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await expect(page.getByRole('button', { name: 'Create Project' })).toBeVisible(); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project name').fill(projectName); + + // Select client in the project create modal + await page.getByRole('dialog').getByRole('button', { name: 'No Client' }).click(); + await page.getByRole('option', { name: clientName }).click(); + + await Promise.all([ + page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/projects') && + response.request().method() === 'POST' && + response.status() === 201 + ), + ]); + await expect(page.getByText(projectName)).toBeVisible(); +} + +export async function createTask(page: Page, projectName: string, taskName: string) { + await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await expect(page.getByText(projectName)).toBeVisible(); + await page.getByText(projectName).click(); + await page.getByRole('button', { name: 'Create Task' }).click(); + await page.getByPlaceholder('Task Name').fill(taskName); + await Promise.all([ + page.getByRole('button', { name: 'Create Task' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/tasks') && + response.request().method() === 'POST' && + response.status() === 201 + ), + ]); + await expect(page.getByText(taskName)).toBeVisible(); +} + +// ────────────────────────────────────────────────── +// Time entry creation +// ────────────────────────────────────────────────── + +export async function createTimeEntryWithProject( + page: Page, + projectName: string, + duration: string +) { + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible(); + await page.getByRole('button', { name: 'Time entry actions' }).click(); + await page.getByRole('menuitem', { name: 'Manual time entry' }).click(); + + await page + .getByRole('dialog') + .getByRole('textbox', { name: 'Description' }) + .fill(`Entry for ${projectName}`); + + await page.getByRole('button', { name: 'No Project' }).click(); + await page.getByRole('option').filter({ hasText: projectName }).click(); + + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + + await Promise.all([ + page.getByRole('button', { name: 'Create Time Entry' }).click(), + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + ]); +} + +export async function createTimeEntryWithProjectAndTask( + page: Page, + projectName: string, + taskName: string, + duration: string +) { + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible(); + await page.getByRole('button', { name: 'Time entry actions' }).click(); + await page.getByRole('menuitem', { name: 'Manual time entry' }).click(); + + await page + .getByRole('dialog') + .getByRole('textbox', { name: 'Description' }) + .fill(`Entry for ${projectName} - ${taskName}`); + + // Open the project/task dropdown + await page.getByRole('button', { name: 'No Project' }).click(); + + // Expand the project's tasks by clicking the "Tasks" button + const projectOption = page.getByRole('option').filter({ hasText: projectName }); + await projectOption.getByText(/Tasks/).click(); + + // Select the task (this also selects the project and closes the dropdown) + await page.getByText(taskName, { exact: true }).click(); + + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + + await Promise.all([ + page.getByRole('button', { name: 'Create Time Entry' }).click(), + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + ]); +} + +export async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) { + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible(); + await page.getByRole('button', { name: 'Time entry actions' }).click(); + await page.getByRole('menuitem', { name: 'Manual time entry' }).click(); + + await page + .getByRole('dialog') + .getByRole('textbox', { name: 'Description' }) + .fill(`Entry with tag ${tagName}`); + + // Add tag + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByText('Create new tag').click(); + await page.getByPlaceholder('Tag Name').fill(tagName); + await Promise.all([ + page.getByRole('button', { name: 'Create Tag' }).click(), + page.waitForResponse( + (response) => response.url().includes('/tags') && response.status() === 201 + ), + ]); + + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + + await Promise.all([ + page.getByRole('button', { name: 'Create Time Entry' }).click(), + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + ]); +} + +export async function createBareTimeEntry(page: Page, description: string, duration: string) { + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible(); + await page.getByRole('button', { name: 'Time entry actions' }).click(); + await page.getByRole('menuitem', { name: 'Manual time entry' }).click(); + + await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(description); + + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + + await Promise.all([ + page.getByRole('button', { name: 'Create Time Entry' }).click(), + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + ]); +} + +export async function createTimeEntryWithBillableStatus( + page: Page, + isBillable: boolean, + duration: string +) { + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible(); + await page.getByRole('button', { name: 'Time entry actions' }).click(); + await page.getByRole('menuitem', { name: 'Manual time entry' }).click(); + + await page + .getByRole('dialog') + .getByRole('textbox', { name: 'Description' }) + .fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`); + + if (isBillable) { + await page + .getByRole('dialog') + .getByRole('combobox') + .filter({ hasText: 'Non-Billable' }) + .click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + } + + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + + await Promise.all([ + page.getByRole('button', { name: 'Create Time Entry' }).click(), + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + ]); +} + +// ────────────────────────────────────────────────── +// Wait helpers +// ────────────────────────────────────────────────── + +export async function waitForReportingUpdate(page: Page) { + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); +} + +export async function waitForDetailedReportingUpdate(page: Page) { + await page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + !response.url().includes('/aggregate') && + response.request().method() === 'GET' && + response.status() === 200 + ); +}