diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts index 5fe115c9..308f4dbc 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -366,6 +366,149 @@ test('test that custom billable rate is displayed correctly on project detail pa ); }); +// Tests for estimated time input (Issue #460) +test('test that creating a project with estimated time in human-readable format works', async ({ + page, +}) => { + const newProjectName = 'Estimated Time Project ' + Math.floor(1 + Math.random() * 10000); + await goToProjectsOverview(page); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project Name').fill(newProjectName); + + // Fill in estimated time using human-readable format + const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5'); + await estimatedTimeInput.fill('2h 30m'); + await estimatedTimeInput.press('Tab'); + + await Promise.all([ + page.getByRole('button', { name: 'Create Project' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects') && + response.request().method() === 'POST' && + response.status() === 201 && + // 2h 30m = 9000 seconds + (await response.json()).data.estimated_time === 9000 + ), + ]); + + await expect(page.getByTestId('project_table')).toContainText(newProjectName); +}); + +test('test that creating a project with estimated time using decimal notation works', async ({ + page, +}) => { + const newProjectName = 'Decimal Estimated Project ' + Math.floor(1 + Math.random() * 10000); + await goToProjectsOverview(page); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project Name').fill(newProjectName); + + // Fill in estimated time using decimal notation (1.5 hours = 1h 30m) + const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5'); + await estimatedTimeInput.fill('1.5'); + await estimatedTimeInput.press('Tab'); + + await Promise.all([ + page.getByRole('button', { name: 'Create Project' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects') && + response.request().method() === 'POST' && + response.status() === 201 && + // 1.5 hours = 5400 seconds + (await response.json()).data.estimated_time === 5400 + ), + ]); + + await expect(page.getByTestId('project_table')).toContainText(newProjectName); +}); + +test('test that creating a project with estimated time using comma decimal notation works', async ({ + page, +}) => { + const newProjectName = 'Comma Decimal Project ' + Math.floor(1 + Math.random() * 10000); + await goToProjectsOverview(page); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project Name').fill(newProjectName); + + // Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m) + const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5'); + await estimatedTimeInput.fill('2,5'); + await estimatedTimeInput.press('Tab'); + + await Promise.all([ + page.getByRole('button', { name: 'Create Project' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects') && + response.request().method() === 'POST' && + response.status() === 201 && + // 2.5 hours = 9000 seconds + (await response.json()).data.estimated_time === 9000 + ), + ]); + + await expect(page.getByTestId('project_table')).toContainText(newProjectName); +}); + +test('test that updating estimated time on existing project works', async ({ page }) => { + const newProjectName = 'Update Estimated Project ' + Math.floor(1 + Math.random() * 10000); + await goToProjectsOverview(page); + + // Create a project first + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project Name').fill(newProjectName); + await Promise.all([ + page.getByRole('button', { name: 'Create Project' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/projects') && + response.request().method() === 'POST' && + response.status() === 201 + ), + ]); + await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); + + // Edit the project to add estimated time + await page.getByRole('row').first().getByRole('button').click(); + await page.getByRole('menuitem').getByText('Edit').first().click(); + + // Fill in estimated time + const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5'); + await estimatedTimeInput.fill('4h 15m'); + await estimatedTimeInput.press('Tab'); + + await Promise.all([ + page.getByRole('button', { name: 'Update Project' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/projects/') && + response.request().method() === 'PUT' && + response.status() === 200 && + // 4h 15m = 15300 seconds + (await response.json()).data.estimated_time === 15300 + ), + ]); +}); + +test('test that estimated time input displays formatted value after blur', async ({ page }) => { + await goToProjectsOverview(page); + await page.getByRole('button', { name: 'Create Project' }).click(); + + const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5'); + + // Enter time in various formats and check the displayed value + await estimatedTimeInput.fill('90'); + await estimatedTimeInput.press('Tab'); + // 90 hours should be displayed as "90h 00min" (default format) + await expect(estimatedTimeInput).toHaveValue(/90h/); + + await estimatedTimeInput.fill('1:30'); + await estimatedTimeInput.press('Tab'); + // 1:30 should be displayed as "1h 30min" + await expect(estimatedTimeInput).toHaveValue(/1h.*30/); +}); + // Create new project with new Client // Create new project with existing Client diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts index 06c7f2eb..8d6a8079 100644 --- a/e2e/time.spec.ts +++ b/e2e/time.spec.ts @@ -694,6 +694,74 @@ test('test that mass update billable status works', async ({ page }) => { ).toBeVisible(); }); +test('test that resetting project selection in mass update modal does not update project', async ({ + page, +}) => { + const projectName = 'Mass Update Reset Project ' + Math.floor(1 + Math.random() * 10000); + await createProject(page, projectName); + + // Create a time entry with the project assigned + await createBareTimeEntry(page, 'Mass update reset test', '1h'); + await goToTimeOverview(page); + + // Assign project to the time entry + const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first(); + await expect(timeEntryRow).toBeVisible(); + await timeEntryRow.getByRole('button', { name: 'No Project' }).click(); + await page.getByRole('option', { name: projectName }).click(); + await expect(timeEntryRow.getByRole('button', { name: projectName })).toBeVisible(); + + // Now open mass update modal + await page.getByLabel('Select All').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + await page.getByRole('button', { name: 'Edit' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // The project dropdown should show "Select project..." (initial unset state) + const projectDropdown = page + .getByRole('dialog') + .getByRole('button', { name: 'Select project...' }); + await expect(projectDropdown).toBeVisible(); + + // Select the project, then click the reset (X) button + await projectDropdown.click(); + await page.getByRole('option', { name: projectName }).click(); + + // Now the dropdown shows the project name, click the X to reset + await expect(page.getByRole('dialog').getByRole('button', { name: projectName })).toBeVisible(); + + // Find and click the reset button (the X icon next to the dropdown) + await page.getByRole('dialog').getByTestId('project_reset_button').click(); + + // After reset, it should show "Select project..." again (not "No Project") + await expect( + page.getByRole('dialog').getByRole('button', { name: 'Select project...' }) + ).toBeVisible(); + + // Submit the mass update - need to make at least one change for the API to accept it + // Change billable status to keep it unchanged by selecting the "Keep current" option + // Actually, we need to verify the reset behavior, so let's just change billable to trigger the request + await page + .getByRole('dialog') + .getByRole('combobox') + .filter({ hasText: 'Set billable status' }) + .click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + + await page.getByRole('button', { name: 'Update Time Entries' }).click(); + + // Wait for dialog to close + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // Verify the time entry still has the original project (was not changed to "No Project") + await expect( + page + .locator('[data-testid="time_entry_row"]') + .first() + .getByRole('button', { name: projectName }) + ).toBeVisible(); +}); + test('test that setting billable status via the create modal works', async ({ page }) => { await goToTimeOverview(page); @@ -817,6 +885,254 @@ test('test that changing project on a time entry row from billable to non-billab expect(responseBody.data.billable).toBe(false); }); +/** + * Tests for TimeEntryCreateModal functionality + */ + +test('test that natural language duration input works in create modal', async ({ page }) => { + await goToTimeOverview(page); + + // Open the create modal + 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('Duration test entry'); + + // Test natural language duration input "2h 30m" + const durationInput = page.locator('[role="dialog"] input[name="Duration"]'); + await durationInput.fill('2h 30m'); + await durationInput.press('Tab'); + + // Verify the duration was parsed correctly (should show "2h 30min") + await expect(durationInput).toHaveValue('2h 30min'); + + // Submit and verify the duration in the response (2h 30m = 9000 seconds) + const [createResponse] = await Promise.all([ + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + page.getByRole('button', { name: 'Create Time Entry' }).click(), + ]); + const createBody = await createResponse.json(); + expect(createBody.data.duration).toBe(9000); +}); + +test('test that decimal duration input works in create modal', async ({ page }) => { + await goToTimeOverview(page); + + // Open the create modal + 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('Decimal duration test'); + + // Test decimal duration input "1.5h" (should be interpreted as 1.5 hours = 90 minutes) + // Note: parse-duration library requires a unit suffix for decimal values + const durationInput = page.locator('[role="dialog"] input[name="Duration"]'); + await durationInput.fill('1.5h'); + await durationInput.press('Tab'); + + // Verify the duration was parsed correctly (should show "1h 30min") + await expect(durationInput).toHaveValue('1h 30min'); + + // Submit and verify the duration in the response (1.5h = 5400 seconds) + const [createResponse] = await Promise.all([ + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + page.getByRole('button', { name: 'Create Time Entry' }).click(), + ]); + const createBody = await createResponse.json(); + expect(createBody.data.duration).toBe(5400); +}); + +test('test that project selection works in create modal', async ({ page }) => { + const projectName = 'Create Modal Project ' + Math.floor(1 + Math.random() * 10000); + await createProject(page, projectName); + + await goToTimeOverview(page); + + // Open the create modal + 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('Project selection test'); + + // Select project + await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); + await page.getByRole('option', { name: projectName }).click(); + + // Verify project is selected + await expect(page.getByRole('dialog').getByRole('button', { name: projectName })).toBeVisible(); + + // 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 project_id is set in response + const [createResponse] = await Promise.all([ + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + page.getByRole('button', { name: 'Create Time Entry' }).click(), + ]); + const createBody = await createResponse.json(); + expect(createBody.data.project_id).not.toBeNull(); +}); + +test('test that tag selection works in create modal', async ({ page }) => { + await goToTimeOverview(page); + + // Open the create modal + 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('Tag selection test'); + + // Open tags dropdown + await page.getByRole('dialog').getByRole('button', { name: 'Tags' }).click(); + + // Create a new tag + const tagName = 'TestTag' + Math.floor(1 + Math.random() * 10000); + await page.getByText('Create new tag').click(); + await page.getByPlaceholder('Tag Name').fill(tagName); + const [tagResponse] = await Promise.all([ + page.waitForResponse( + (response) => response.url().includes('/tags') && response.status() === 201 + ), + page.getByRole('button', { name: 'Create Tag' }).click(), + ]); + const tagBody = await tagResponse.json(); + const tagId = tagBody.data.id; + + // Verify tag button now shows "1 Tag" + await expect(page.getByRole('dialog').getByRole('button', { name: '1 Tag' })).toBeVisible(); + + // 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 tags array contains the created tag + const [createResponse] = await Promise.all([ + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + page.getByRole('button', { name: 'Create Time Entry' }).click(), + ]); + const createBody = await createResponse.json(); + expect(createBody.data.tags).toContain(tagId); +}); + +test('test that tags dropdown does not show No Tag option in create modal', async ({ page }) => { + await goToTimeOverview(page); + + // Open the create modal + await page.getByRole('button', { name: 'Time entry actions' }).click(); + await page.getByRole('menuitem', { name: 'Manual time entry' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Open tags dropdown + await page.getByRole('dialog').getByRole('button', { name: 'Tags' }).click(); + + // Verify "No Tag" option is not visible + await expect(page.getByText('No Tag')).not.toBeVisible(); +}); + +test('test that start time picker works in create modal', async ({ page }) => { + await goToTimeOverview(page); + + // Open the create modal + 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('Time picker test'); + + // Set duration first (so it doesn't recalculate start time when we set it) + await page.locator('[role="dialog"] input[name="Duration"]').fill('1h'); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + + // Find the start time input (first time_picker_input in the modal) + const modal = page.getByRole('dialog'); + const startTimeInput = modal.getByTestId('time_picker_input').first(); + await startTimeInput.fill('09:30'); + await startTimeInput.press('Tab'); + + // Verify the time picker input shows the correct value + await expect(startTimeInput).toHaveValue('09:30'); + + // Submit and verify the time entry was created + const [createResponse] = await Promise.all([ + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + page.getByRole('button', { name: 'Create Time Entry' }).click(), + ]); + const createBody = await createResponse.json(); + // The start time should contain 09:30 in the timestamp + expect(createBody.data.start).toMatch(/09:30/); +}); + +test('test that end time picker works in create modal', async ({ page }) => { + await goToTimeOverview(page); + + // Open the create modal + 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('End time picker test'); + + // Find the end time input (second time_picker_input in the modal) + const modal = page.getByRole('dialog'); + const endTimeInput = modal.getByTestId('time_picker_input').nth(1); + await endTimeInput.fill('17:45'); + await endTimeInput.press('Tab'); + + // Set duration (this will adjust based on the times) + await page.locator('[role="dialog"] input[name="Duration"]').fill('1h'); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + + // Submit and verify end time contains 17:45 + const [createResponse] = await Promise.all([ + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + page.getByRole('button', { name: 'Create Time Entry' }).click(), + ]); + const createBody = await createResponse.json(); + // The end time should be set (we filled duration after, so it recalculates) + expect(createBody.data.end).toBeTruthy(); +}); + test('test that changing project in edit modal from non-billable to billable updates billable status', async ({ page, }) => { diff --git a/resources/js/Components/CommandPalette/CommandPaletteProvider.vue b/resources/js/Components/CommandPalette/CommandPaletteProvider.vue index c597aa61..c172aa55 100644 --- a/resources/js/Components/CommandPalette/CommandPaletteProvider.vue +++ b/resources/js/Components/CommandPalette/CommandPaletteProvider.vue @@ -201,6 +201,7 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? ''); projects.value[0]?.id ?? ''); :can-create-project="canCreateProjects()" :currency="getOrganizationCurrencyString()" :enable-estimated-time="isAllowedToPerformPremiumAction()" - size="xlarge" class="w-full" />