mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
Use locale-aware parseTimeInput for duration inputs
This commit is contained in:
514
e2e/time.spec.ts
514
e2e/time.spec.ts
@@ -15,6 +15,7 @@ import {
|
|||||||
createBareTimeEntryViaApi,
|
createBareTimeEntryViaApi,
|
||||||
createTimeEntryViaApi,
|
createTimeEntryViaApi,
|
||||||
updateOrganizationCurrencyViaWeb,
|
updateOrganizationCurrencyViaWeb,
|
||||||
|
updateOrganizationSettingViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
|
|
||||||
// Date picker button name patterns for different date formats
|
// 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);
|
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);
|
await goToTimeOverview(page);
|
||||||
|
|
||||||
// Open the create modal
|
// Open the create modal
|
||||||
@@ -978,7 +984,6 @@ test('test that decimal duration input works in create modal', async ({ page })
|
|||||||
.fill('Decimal duration test');
|
.fill('Decimal duration test');
|
||||||
|
|
||||||
// Test decimal duration input "1.5h" (should be interpreted as 1.5 hours = 90 minutes)
|
// 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"]');
|
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||||
await durationInput.fill('1.5h');
|
await durationInput.fill('1.5h');
|
||||||
await durationInput.press('Tab');
|
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);
|
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 }) => {
|
test('test that project selection works in create modal', async ({ page, ctx }) => {
|
||||||
const projectName = 'Create Modal Project ' + Math.floor(1 + Math.random() * 10000);
|
const projectName = 'Create Modal Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
await createProjectViaApi(ctx, { name: projectName });
|
await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import parse from 'parse-duration';
|
|
||||||
import { onMounted, ref, watch, inject } from 'vue';
|
import { onMounted, ref, watch, inject } from 'vue';
|
||||||
import { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
|
import {
|
||||||
|
formatHumanReadableDuration,
|
||||||
|
getDayJsInstance,
|
||||||
|
parseTimeInput,
|
||||||
|
} from '@/packages/ui/src/utils/time';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import { TextInput } from '@/packages/ui/src';
|
import { TextInput } from '@/packages/ui/src';
|
||||||
@@ -20,51 +23,23 @@ const end = defineModel('end', {
|
|||||||
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
|
|
||||||
function isHHMM(value: string): boolean {
|
|
||||||
return HHMMtimeRegex.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseHHMM(value: string): string[] | null {
|
|
||||||
return value.match(HHMMtimeRegex);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDuration() {
|
function updateDuration() {
|
||||||
const time = parse(temporaryCustomTimerEntry.value, 's');
|
const seconds = parseTimeInput(
|
||||||
|
temporaryCustomTimerEntry.value,
|
||||||
if (isNumeric(temporaryCustomTimerEntry.value)) {
|
organization?.value?.number_format,
|
||||||
const newStartDate = getDayJsInstance()(end.value).subtract(
|
organization?.value?.interval_format === 'decimal' ? 'hours' : 'minutes'
|
||||||
parseInt(temporaryCustomTimerEntry.value),
|
);
|
||||||
'm'
|
if (seconds && seconds > 0) {
|
||||||
);
|
const newStartDate = getDayJsInstance()(end.value).subtract(seconds, 's');
|
||||||
start.value = newStartDate.utc().format();
|
|
||||||
} else if (isHHMM(temporaryCustomTimerEntry.value)) {
|
|
||||||
const results = parseHHMM(temporaryCustomTimerEntry.value);
|
|
||||||
if (results) {
|
|
||||||
const newStartDate = getDayJsInstance()(end.value)
|
|
||||||
.subtract(parseInt(results[1]!), 'h')
|
|
||||||
.subtract(parseInt(results[2]!), 'm');
|
|
||||||
start.value = newStartDate.utc().format();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// try to parse natural language like "1h 30m"
|
|
||||||
else if (time && time > 1) {
|
|
||||||
const newStartDate = getDayJsInstance()(end.value).subtract(time, 's');
|
|
||||||
start.value = newStartDate.utc().format();
|
start.value = newStartDate.utc().format();
|
||||||
}
|
}
|
||||||
// fallback to minutes if just a number is given
|
|
||||||
updateTimeEntryInputValue();
|
updateTimeEntryInputValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNumeric(value: string) {
|
|
||||||
return /^-?\d+$/.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: string;
|
class?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
|
|
||||||
|
|
||||||
watch([start, end], updateTimeEntryInputValue);
|
watch([start, end], updateTimeEntryInputValue);
|
||||||
onMounted(() => updateTimeEntryInputValue());
|
onMounted(() => updateTimeEntryInputValue());
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ function updateDuration() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use parseTimeInput with 'hours' as default unit for estimated time
|
const seconds = parseTimeInput(
|
||||||
const seconds = parseTimeInput(input, 'hours');
|
input,
|
||||||
|
organization?.value?.number_format,
|
||||||
|
'hours'
|
||||||
|
);
|
||||||
if (seconds !== null && seconds > 0) {
|
if (seconds !== null && seconds > 0) {
|
||||||
model.value = seconds;
|
model.value = seconds;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ const temporaryCustomTimerEntry = ref<string>('');
|
|||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
|
|
||||||
function updateTimerAndStartLiveTimerUpdate() {
|
function updateTimerAndStartLiveTimerUpdate() {
|
||||||
const defaultUnit =
|
const seconds = parseTimeInput(
|
||||||
organizationSettings?.value?.intervalFormat === 'decimal' ? 'hours' : 'minutes';
|
temporaryCustomTimerEntry.value,
|
||||||
const seconds = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit);
|
organizationSettings.value.numberFormat,
|
||||||
|
organizationSettings.value.intervalFormat === 'decimal' ? 'hours' : 'minutes'
|
||||||
|
);
|
||||||
if (seconds && seconds > 0) {
|
if (seconds && seconds > 0) {
|
||||||
let newEndDate = props.end;
|
let newEndDate = props.end;
|
||||||
let newStartDate = props.start;
|
let newStartDate = props.start;
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const currentTime = computed({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function updateTimerAndStartLiveTimerUpdate() {
|
function updateTimerAndStartLiveTimerUpdate() {
|
||||||
const seconds = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');
|
const seconds = parseTimeInput(temporaryCustomTimerEntry.value);
|
||||||
|
|
||||||
if (seconds && seconds > 0) {
|
if (seconds && seconds > 0) {
|
||||||
const newStartDate = dayjs().subtract(seconds, 's');
|
const newStartDate = dayjs().subtract(seconds, 's');
|
||||||
|
|||||||
@@ -39,7 +39,31 @@ export type IntervalFormat =
|
|||||||
| 'hours-minutes'
|
| 'hours-minutes'
|
||||||
| 'hours-minutes-colon-separated'
|
| 'hours-minutes-colon-separated'
|
||||||
| 'hours-minutes-seconds-colon-separated';
|
| 'hours-minutes-seconds-colon-separated';
|
||||||
export type TimeInputUnit = 'minutes' | 'hours';
|
function configureParseLocale(numberFormat?: string) {
|
||||||
|
switch (numberFormat) {
|
||||||
|
case 'point-comma':
|
||||||
|
parse.unit.group = '.';
|
||||||
|
parse.unit.decimal = ',';
|
||||||
|
break;
|
||||||
|
case 'space-comma':
|
||||||
|
parse.unit.group = ' ';
|
||||||
|
parse.unit.decimal = ',';
|
||||||
|
break;
|
||||||
|
case 'space-point':
|
||||||
|
parse.unit.group = ' ';
|
||||||
|
parse.unit.decimal = '.';
|
||||||
|
break;
|
||||||
|
case 'apostrophe-point':
|
||||||
|
parse.unit.group = "'";
|
||||||
|
parse.unit.decimal = '.';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// 'comma-point' or unset — default English
|
||||||
|
parse.unit.group = ',';
|
||||||
|
parse.unit.decimal = '.';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
dayjs.extend(isToday);
|
dayjs.extend(isToday);
|
||||||
@@ -210,8 +234,11 @@ export function formatStartEnd(
|
|||||||
|
|
||||||
export function parseTimeInput(
|
export function parseTimeInput(
|
||||||
input: string,
|
input: string,
|
||||||
defaultUnit: TimeInputUnit = 'minutes'
|
numberFormat?: string,
|
||||||
|
defaultUnit: 'minutes' | 'hours' = 'minutes'
|
||||||
): number | null {
|
): number | null {
|
||||||
|
configureParseLocale(numberFormat);
|
||||||
|
|
||||||
// Check if input is a decimal number (hours)
|
// Check if input is a decimal number (hours)
|
||||||
const decimalRegex = /^-?\d+[.,]\d+$/;
|
const decimalRegex = /^-?\d+[.,]\d+$/;
|
||||||
if (decimalRegex.test(input)) {
|
if (decimalRegex.test(input)) {
|
||||||
@@ -219,10 +246,10 @@ export function parseTimeInput(
|
|||||||
return Math.round(hours * 3600);
|
return Math.round(hours * 3600);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if input is just a number (minutes or hours based on defaultUnit)
|
// Check if input is just a number
|
||||||
if (/^-?\d+$/.test(input)) {
|
if (/^-?\d+$/.test(input)) {
|
||||||
const value = parseInt(input);
|
const value = parseInt(input);
|
||||||
return defaultUnit === 'minutes' ? value * 60 : value * 3600;
|
return defaultUnit === 'hours' ? value * 3600 : value * 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if input is in HH:MM:SS format
|
// Check if input is in HH:MM:SS format
|
||||||
@@ -248,7 +275,7 @@ export function parseTimeInput(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse natural language like "1h 30m"
|
// Try to parse natural language like "1h 30m" or locale-formatted like "1,00 h"
|
||||||
const parsedDuration = parse(input, 's');
|
const parsedDuration = parse(input, 's');
|
||||||
if (parsedDuration && parsedDuration > 0) {
|
if (parsedDuration && parsedDuration > 0) {
|
||||||
return parsedDuration;
|
return parsedDuration;
|
||||||
|
|||||||
Reference in New Issue
Block a user