mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-13 02:32:15 +01:00
improve time estimate input, responsive time entry create modal fixes,
fixes #460, #800
This commit is contained in:
@@ -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
|
||||
|
||||
316
e2e/time.spec.ts
316
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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user