improve time estimate input, responsive time entry create modal fixes,

fixes #460, #800
This commit is contained in:
Gregor Vostrak
2026-02-06 14:35:52 +01:00
parent d2644112c5
commit bbe05ca0d8
16 changed files with 760 additions and 301 deletions

View File

@@ -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

View File

@@ -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,
}) => {