diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts index a9b72e14..58edc62e 100644 --- a/e2e/time.spec.ts +++ b/e2e/time.spec.ts @@ -15,6 +15,7 @@ import { createBareTimeEntryViaApi, createTimeEntryViaApi, updateOrganizationCurrencyViaWeb, + updateOrganizationSettingViaApi, } from './utils/api'; // Date picker button name patterns for different date formats @@ -963,7 +964,12 @@ test('test that natural language duration input works in create modal', async ({ expect(createBody.data.duration).toBe(9000); }); -test('test that decimal duration input works in create modal', async ({ page }) => { +test('test that decimal duration input works in create modal', async ({ page, ctx }) => { + // Ensure comma-point format so "1.5h" uses period as decimal + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'hours-minutes', + number_format: 'comma-point', + }); await goToTimeOverview(page); // Open the create modal @@ -978,7 +984,6 @@ test('test that decimal duration input works in create modal', async ({ page }) .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'); @@ -997,6 +1002,511 @@ test('test that decimal duration input works in create modal', async ({ page }) expect(createBody.data.duration).toBe(5400); }); +test('test that decimal duration with comma number format does not corrupt on blur in edit modal', async ({ + page, + ctx, +}) => { + // Set organization to decimal interval format with European number format (comma as decimal separator) + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'decimal', + number_format: 'point-comma', + }); + + // Create a 1-hour time entry + await createBareTimeEntryViaApi(ctx, 'Decimal blur test', '1h'); + await goToTimeOverview(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + const newTimeEntry = timeEntryRows.first(); + + // 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(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // The duration input should show "1,00 h" (decimal format with comma) + const durationInput = page.locator('[role="dialog"] input[name="Duration"]'); + await expect(durationInput).toHaveValue('1,00 h'); + + // Click on the duration input and blur it without changing the value + await durationInput.click(); + await durationInput.press('Tab'); + + // After blur, the value should remain "1,00 h" and NOT become "100,00 h" + await expect(durationInput).toHaveValue('1,00 h'); + + // Submit and verify the duration is still 3600 seconds (1 hour) + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const updateBody = await updateResponse.json(); + expect(updateBody.data.duration).toBe(3600); + + // Reset organization settings + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'hours-minutes', + number_format: 'comma-point', + }); +}); + +test('test that typing bare decimal 1,5 in edit modal is interpreted as 1.5 hours', async ({ + page, + ctx, +}) => { + // Set organization to decimal interval format with European number format + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'decimal', + number_format: 'point-comma', + }); + + // Create a 1-hour time entry + await createBareTimeEntryViaApi(ctx, 'Bare decimal test', '1h'); + await goToTimeOverview(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + const newTimeEntry = timeEntryRows.first(); + + // Open edit modal + const actionsDropdown = newTimeEntry + .getByRole('button', { name: 'Actions for the time entry' }) + .first(); + await actionsDropdown.click(); + await page.getByTestId('time_entry_edit').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Type "1,5" (bare decimal without "h" suffix) — should be interpreted as 1.5 hours + const durationInput = page.locator('[role="dialog"] input[name="Duration"]'); + await durationInput.fill('1,5'); + await durationInput.press('Tab'); + + // Should display as "1,50 h" (1.5 hours formatted in point-comma locale) + await expect(durationInput).toHaveValue('1,50 h'); + + // Submit and verify the duration is 5400 seconds (1.5 hours) + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const updateBody = await updateResponse.json(); + expect(updateBody.data.duration).toBe(5400); + + // Reset organization settings + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'hours-minutes', + number_format: 'comma-point', + }); +}); + +test('test that typing bare decimal 1.5 in edit modal is interpreted as 1.5 hours', async ({ + page, + ctx, +}) => { + // Set organization to decimal interval format with default number format + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'decimal', + number_format: 'comma-point', + }); + + // Create a 1-hour time entry + await createBareTimeEntryViaApi(ctx, 'Bare decimal dot test', '1h'); + await goToTimeOverview(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + const newTimeEntry = timeEntryRows.first(); + + // Open edit modal + const actionsDropdown = newTimeEntry + .getByRole('button', { name: 'Actions for the time entry' }) + .first(); + await actionsDropdown.click(); + await page.getByTestId('time_entry_edit').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Type "1.5" (bare decimal with period) — should be interpreted as 1.5 hours + const durationInput = page.locator('[role="dialog"] input[name="Duration"]'); + await durationInput.fill('1.5'); + await durationInput.press('Tab'); + + // Should display as "1.50 h" (1.5 hours formatted in comma-point locale) + await expect(durationInput).toHaveValue('1.50 h'); + + // Submit and verify the duration is 5400 seconds (1.5 hours) + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const updateBody = await updateResponse.json(); + expect(updateBody.data.duration).toBe(5400); + + // Reset organization settings + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'hours-minutes', + number_format: 'comma-point', + }); +}); + +test('test that decimal duration with space-comma number format does not corrupt on blur in edit modal', async ({ + page, + ctx, +}) => { + // Set organization to decimal interval format with space-comma number format + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'decimal', + number_format: 'space-comma', + }); + + // Create a 1-hour time entry + await createBareTimeEntryViaApi(ctx, 'Space-comma blur test', '1h'); + await goToTimeOverview(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + const newTimeEntry = timeEntryRows.first(); + + // Open edit modal + const actionsDropdown = newTimeEntry + .getByRole('button', { name: 'Actions for the time entry' }) + .first(); + await actionsDropdown.click(); + await page.getByTestId('time_entry_edit').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // The duration input should show "1,00 h" (space-comma uses comma as decimal) + const durationInput = page.locator('[role="dialog"] input[name="Duration"]'); + await expect(durationInput).toHaveValue('1,00 h'); + + // Blur without changing the value + await durationInput.click(); + await durationInput.press('Tab'); + + // Should remain "1,00 h" + await expect(durationInput).toHaveValue('1,00 h'); + + // Submit and verify the duration is still 3600 seconds + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const updateBody = await updateResponse.json(); + expect(updateBody.data.duration).toBe(3600); + + // Reset organization settings + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'hours-minutes', + number_format: 'comma-point', + }); +}); + +test('test that bare integer in edit modal is interpreted as minutes', async ({ page, ctx }) => { + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'hours-minutes', + number_format: 'comma-point', + }); + + await createBareTimeEntryViaApi(ctx, 'Bare integer test', '1h'); + await goToTimeOverview(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + const newTimeEntry = timeEntryRows.first(); + + // Open edit modal + const actionsDropdown = newTimeEntry + .getByRole('button', { name: 'Actions for the time entry' }) + .first(); + await actionsDropdown.click(); + await page.getByTestId('time_entry_edit').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Type "30" — should be interpreted as 30 minutes + const durationInput = page.locator('[role="dialog"] input[name="Duration"]'); + await durationInput.fill('30'); + await durationInput.press('Tab'); + + // Should display as "0h 30min" + await expect(durationInput).toHaveValue('0h 30min'); + + // Submit and verify the duration is 1800 seconds (30 minutes) + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const updateBody = await updateResponse.json(); + expect(updateBody.data.duration).toBe(1800); +}); + +test('test that bare integer in edit modal with decimal format is interpreted as hours', async ({ + page, + ctx, +}) => { + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'decimal', + number_format: 'comma-point', + }); + + await createBareTimeEntryViaApi(ctx, 'Bare integer decimal test', '1h'); + await goToTimeOverview(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + const newTimeEntry = timeEntryRows.first(); + + // Open edit modal + const actionsDropdown = newTimeEntry + .getByRole('button', { name: 'Actions for the time entry' }) + .first(); + await actionsDropdown.click(); + await page.getByTestId('time_entry_edit').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Type "2" — with decimal format, should be interpreted as 2 hours + const durationInput = page.locator('[role="dialog"] input[name="Duration"]'); + await durationInput.fill('2'); + await durationInput.press('Tab'); + + // Should display as "2.00 h" + await expect(durationInput).toHaveValue('2.00 h'); + + // Submit and verify the duration is 7200 seconds (2 hours) + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const updateBody = await updateResponse.json(); + expect(updateBody.data.duration).toBe(7200); + + // Reset organization settings + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'hours-minutes', + number_format: 'comma-point', + }); +}); + +test('test that HH:MM input in edit modal works', async ({ page, ctx }) => { + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'hours-minutes', + number_format: 'comma-point', + }); + + await createBareTimeEntryViaApi(ctx, 'HH:MM test', '1h'); + await goToTimeOverview(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + const newTimeEntry = timeEntryRows.first(); + + // Open edit modal + const actionsDropdown = newTimeEntry + .getByRole('button', { name: 'Actions for the time entry' }) + .first(); + await actionsDropdown.click(); + await page.getByTestId('time_entry_edit').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Type "1:30" — should be interpreted as 1 hour 30 minutes + const durationInput = page.locator('[role="dialog"] input[name="Duration"]'); + await durationInput.fill('1:30'); + await durationInput.press('Tab'); + + // Should display as "1h 30min" + await expect(durationInput).toHaveValue('1h 30min'); + + // Submit and verify the duration is 5400 seconds (1.5 hours) + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const updateBody = await updateResponse.json(); + expect(updateBody.data.duration).toBe(5400); +}); + +test('test that bare integer in inline duration input is interpreted as minutes', async ({ + page, + ctx, +}) => { + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'hours-minutes', + number_format: 'comma-point', + }); + + await createBareTimeEntryViaApi(ctx, 'Inline bare integer test', '1h'); + await goToTimeOverview(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + const newTimeEntry = timeEntryRows.first(); + + // Type "45" in the inline duration input — should be 45 minutes + const durationInput = newTimeEntry.getByTestId('time_entry_duration_input').first(); + await durationInput.click(); + await durationInput.fill('45'); + + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + durationInput.press('Tab'), + ]); + const updateBody = await updateResponse.json(); + expect(updateBody.data.duration).toBe(2700); +}); + +test('test that bare integer in inline duration input with decimal format is interpreted as hours', async ({ + page, + ctx, +}) => { + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'decimal', + number_format: 'comma-point', + }); + + await createBareTimeEntryViaApi(ctx, 'Inline bare integer decimal test', '1h'); + await goToTimeOverview(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + const newTimeEntry = timeEntryRows.first(); + + // Type "3" in the inline duration input — with decimal format, should be 3 hours + const durationInput = newTimeEntry.getByTestId('time_entry_duration_input').first(); + await durationInput.click(); + await durationInput.fill('3'); + + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + durationInput.press('Tab'), + ]); + const updateBody = await updateResponse.json(); + expect(updateBody.data.duration).toBe(10800); + + // Reset organization settings + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'hours-minutes', + number_format: 'comma-point', + }); +}); + +test('test that bare integer in create modal is interpreted as minutes', async ({ + page, + ctx, +}) => { + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'hours-minutes', + number_format: 'comma-point', + }); + 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(); + + await page + .getByRole('dialog') + .getByRole('textbox', { name: 'Description' }) + .fill('Bare integer create test'); + + // Type "30" — should be interpreted as 30 minutes + const durationInput = page.locator('[role="dialog"] input[name="Duration"]'); + await durationInput.fill('30'); + await durationInput.press('Tab'); + + await expect(durationInput).toHaveValue('0h 30min'); + + 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(1800); +}); + +test('test that bare integer in create modal with decimal format is interpreted as hours', async ({ + page, + ctx, +}) => { + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'decimal', + number_format: 'comma-point', + }); + 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(); + + await page + .getByRole('dialog') + .getByRole('textbox', { name: 'Description' }) + .fill('Bare integer decimal create test'); + + // Type "2" — with decimal format, should be interpreted as 2 hours + const durationInput = page.locator('[role="dialog"] input[name="Duration"]'); + await durationInput.fill('2'); + await durationInput.press('Tab'); + + await expect(durationInput).toHaveValue('2.00 h'); + + 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(7200); + + // Reset organization settings + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'hours-minutes', + number_format: 'comma-point', + }); +}); + test('test that project selection works in create modal', async ({ page, ctx }) => { const projectName = 'Create Modal Project ' + Math.floor(1 + Math.random() * 10000); await createProjectViaApi(ctx, { name: projectName }); diff --git a/resources/js/packages/ui/src/Input/DurationHumanInput.vue b/resources/js/packages/ui/src/Input/DurationHumanInput.vue index 7df9507e..2c0e44f3 100644 --- a/resources/js/packages/ui/src/Input/DurationHumanInput.vue +++ b/resources/js/packages/ui/src/Input/DurationHumanInput.vue @@ -1,7 +1,10 @@