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,
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user