Conditionally show cost column in report tables; Task/Project Modal

Field cleanup; improve estimated time UX
This commit is contained in:
Gregor Vostrak
2026-02-11 17:09:32 +01:00
parent abfa7cea0d
commit 3c9159f2d4
18 changed files with 664 additions and 266 deletions

View File

@@ -8,7 +8,12 @@ import {
startOrStopTimerWithButton,
stoppedTimeEntryResponse,
} from './utils/currentTimeEntry';
import { createBareTimeEntryViaApi } from './utils/api';
import {
createBareTimeEntryViaApi,
createPublicProjectViaApi,
createTimeEntryViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
async function goToDashboard(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
@@ -116,4 +121,70 @@ test.describe('Employee Dashboard Restrictions', () => {
// Team Activity should NOT be visible for employees
await expect(employee.page.getByText('Team Activity', { exact: true })).not.toBeVisible();
});
test('employee cannot see Cost column in This Week table by default', async ({
ctx,
employee,
}) => {
const project = await createPublicProjectViaApi(ctx, {
name: 'EmpDashBillProj',
is_billable: true,
billable_rate: 10000,
});
await createTimeEntryViaApi(
{ ...ctx, memberId: employee.memberId },
{
description: 'Emp dashboard cost entry',
duration: '1h',
projectId: project.id,
billable: true,
}
);
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// This Week table should be visible
await expect(employee.page.getByText('This Week', { exact: true })).toBeVisible();
// Duration column should be visible, but Cost column should NOT
await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible();
await expect(employee.page.getByText('Cost', { exact: true })).not.toBeVisible();
});
test('employee can see Cost column in This Week table when employees_can_see_billable_rates is enabled', async ({
ctx,
employee,
}) => {
await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true });
const project = await createPublicProjectViaApi(ctx, {
name: 'EmpDashBillVisProj',
is_billable: true,
billable_rate: 10000,
});
await createTimeEntryViaApi(
{ ...ctx, memberId: employee.memberId },
{
description: 'Emp dashboard cost visible entry',
duration: '1h',
projectId: project.id,
billable: true,
}
);
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// Both Duration and Cost columns should be visible
await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible();
await expect(employee.page.getByText('Cost', { exact: true })).toBeVisible();
// 1h at 100.00/h = 100.00 EUR cost should be visible
await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible();
});
});

View File

@@ -124,8 +124,15 @@ test('test that updating billable rate works with existing time entries', async
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').first().click();
await page.getByText('Non-Billable').click();
await page.getByText('Custom Rate').click();
// Set billable default to Billable
await page.getByRole('dialog').locator('#billable').click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
// Set billable rate to Custom Rate
await page.getByRole('dialog').locator('#billableRateType').click();
await page.getByRole('option', { name: 'Custom Rate' }).click();
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Project' }).click();
@@ -153,6 +160,180 @@ test('test that updating billable rate works with existing time entries', async
).toBeVisible();
});
test('test that creating a project with default billable rate works', async ({ page }) => {
const newProjectName = 'Default Rate 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);
// Set billable default to Billable (leaves rate type as Default Rate)
await page.getByRole('dialog').locator('#billable').click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
// Verify rate type is "Default Rate" and the rate input is disabled
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
'Default Rate'
);
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
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 &&
(await response.json()).data.is_billable === true &&
(await response.json()).data.billable_rate === null
),
]);
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});
test('test that creating a non-billable project works', async ({ page }) => {
const newProjectName = 'Non-Billable 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);
// Billable default should already be "Non-billable" by default
await expect(page.getByRole('dialog').locator('#billable')).toContainText('Non-billable');
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 &&
(await response.json()).data.is_billable === false &&
(await response.json()).data.billable_rate === null
),
]);
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});
test('test that switching from custom rate to default rate clears billable rate', async ({
page,
ctx,
}) => {
const newProjectName = 'Rate Switch Project ' + Math.floor(1 + Math.random() * 10000);
// Create a project with an existing custom billable rate
await createProjectViaApi(ctx, {
name: newProjectName,
is_billable: true,
billable_rate: 15000,
});
await goToProjectsOverview(page);
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').first().click();
// Verify it loaded as Billable with Custom Rate
await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
'Custom Rate'
);
// Switch to Default Rate
await page.getByRole('dialog').locator('#billableRateType').click();
await page.getByRole('option', { name: 'Default Rate' }).click();
// Rate input should now be disabled
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
// Submit — billable_rate changes from 15000 to null, so confirmation dialog appears
await page.getByRole('button', { name: 'Update Project' }).click();
await Promise.all([
page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.is_billable === true &&
(await response.json()).data.billable_rate === null
),
]);
});
test('test that switching from billable to non-billable preserves rate settings', async ({
page,
ctx,
}) => {
const newProjectName = 'Billable Reset Project ' + Math.floor(1 + Math.random() * 10000);
// Create a project with a custom billable rate
await createProjectViaApi(ctx, {
name: newProjectName,
is_billable: true,
billable_rate: 20000,
});
await goToProjectsOverview(page);
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').first().click();
// Verify it loaded correctly as Billable with Custom Rate
await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
'Custom Rate'
);
// Switch to Non-billable
await page.getByRole('dialog').locator('#billable').click();
await page.getByRole('option', { name: 'Non-billable' }).click();
// Rate type should still be Custom Rate (not reset)
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
'Custom Rate'
);
// Submit and verify project is non-billable but keeps its custom rate
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 &&
(await response.json()).data.is_billable === false &&
(await response.json()).data.billable_rate === 20000
),
]);
});
test('test that editing an existing billable project with default rate loads correctly', async ({
page,
ctx,
}) => {
const newProjectName = 'Default Rate Edit Project ' + Math.floor(1 + Math.random() * 10000);
// Create a project that is billable but has no custom rate (= default rate)
await createProjectViaApi(ctx, {
name: newProjectName,
is_billable: true,
billable_rate: null,
});
await goToProjectsOverview(page);
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').first().click();
// Verify it loaded as Billable with Default Rate
await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
'Default Rate'
);
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
});
// Sorting tests
test('test that sorting projects by name works', async ({ page }) => {
await goToProjectsOverview(page);
@@ -296,8 +477,15 @@ test('test that custom billable rate is displayed correctly on project detail pa
// Edit the project to set a custom billable rate
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').first().click();
await page.getByText('Non-Billable').click();
await page.getByText('Custom Rate').click();
// Set billable default to Billable
await page.getByRole('dialog').locator('#billable').click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
// Set billable rate to Custom Rate
await page.getByRole('dialog').locator('#billableRateType').click();
await page.getByRole('option', { name: 'Custom Rate' }).click();
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Project' }).click();

View File

@@ -195,6 +195,69 @@ test('test that multiple tasks are displayed on project detail page', async ({ p
await expect(page.getByText(taskName2)).toBeVisible();
});
test('test that creating a new project from the task create modal project dropdown works', async ({
page,
ctx,
}) => {
const existingProjectName = 'Existing Project ' + Math.floor(1 + Math.random() * 10000);
const newProjectName = 'Dropdown Created Project ' + Math.floor(1 + Math.random() * 10000);
const newTaskName = 'Task With New Project ' + Math.floor(1 + Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: existingProjectName });
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
// Open the Create Task modal
await page.getByRole('button', { name: 'Create Task' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByPlaceholder('Task Name').fill(newTaskName);
// Open the project dropdown (it should show the current project)
await page.getByRole('dialog').getByRole('button', { name: existingProjectName }).click();
// Click "Create new Project" at the bottom of the dropdown
await page.getByText('Create new Project').click();
// The ProjectCreateModal should appear
await expect(page.getByLabel('Project name')).toBeVisible();
await page.getByLabel('Project name').fill(newProjectName);
// Submit the project creation
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 &&
(await response.json()).data.name === newProjectName
),
]);
// The project dropdown trigger should now show the new project name
await expect(
page.getByRole('dialog').getByRole('button', { name: newProjectName })
).toBeVisible();
// Submit the task and capture the response to get the new project ID
const [taskResponse] = await Promise.all([
page.waitForResponse(
async (response) =>
response.url().includes('/tasks') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.name === newTaskName
),
page.getByRole('button', { name: 'Create Task' }).click(),
]);
const taskData = await taskResponse.json();
const newProjectId = taskData.data.project_id;
// Navigate to the new project's page and verify the task is there
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + newProjectId);
await expect(page.getByTestId('task_table')).toContainText(newTaskName);
});
// =============================================
// Employee Permission Tests
// =============================================