Skip to content

Commit 3c9159f

Browse files
committed
Conditionally show cost column in report tables; Task/Project Modal
Field cleanup; improve estimated time UX
1 parent abfa7ce commit 3c9159f

18 files changed

+663
-265
lines changed

e2e/dashboard.spec.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import {
88
startOrStopTimerWithButton,
99
stoppedTimeEntryResponse,
1010
} from './utils/currentTimeEntry';
11-
import { createBareTimeEntryViaApi } from './utils/api';
11+
import {
12+
createBareTimeEntryViaApi,
13+
createPublicProjectViaApi,
14+
createTimeEntryViaApi,
15+
updateOrganizationSettingViaApi,
16+
} from './utils/api';
1217

1318
async function goToDashboard(page: Page) {
1419
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
@@ -116,4 +121,70 @@ test.describe('Employee Dashboard Restrictions', () => {
116121
// Team Activity should NOT be visible for employees
117122
await expect(employee.page.getByText('Team Activity', { exact: true })).not.toBeVisible();
118123
});
124+
125+
test('employee cannot see Cost column in This Week table by default', async ({
126+
ctx,
127+
employee,
128+
}) => {
129+
const project = await createPublicProjectViaApi(ctx, {
130+
name: 'EmpDashBillProj',
131+
is_billable: true,
132+
billable_rate: 10000,
133+
});
134+
await createTimeEntryViaApi(
135+
{ ...ctx, memberId: employee.memberId },
136+
{
137+
description: 'Emp dashboard cost entry',
138+
duration: '1h',
139+
projectId: project.id,
140+
billable: true,
141+
}
142+
);
143+
144+
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
145+
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
146+
timeout: 10000,
147+
});
148+
149+
// This Week table should be visible
150+
await expect(employee.page.getByText('This Week', { exact: true })).toBeVisible();
151+
152+
// Duration column should be visible, but Cost column should NOT
153+
await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible();
154+
await expect(employee.page.getByText('Cost', { exact: true })).not.toBeVisible();
155+
});
156+
157+
test('employee can see Cost column in This Week table when employees_can_see_billable_rates is enabled', async ({
158+
ctx,
159+
employee,
160+
}) => {
161+
await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true });
162+
163+
const project = await createPublicProjectViaApi(ctx, {
164+
name: 'EmpDashBillVisProj',
165+
is_billable: true,
166+
billable_rate: 10000,
167+
});
168+
await createTimeEntryViaApi(
169+
{ ...ctx, memberId: employee.memberId },
170+
{
171+
description: 'Emp dashboard cost visible entry',
172+
duration: '1h',
173+
projectId: project.id,
174+
billable: true,
175+
}
176+
);
177+
178+
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
179+
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
180+
timeout: 10000,
181+
});
182+
183+
// Both Duration and Cost columns should be visible
184+
await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible();
185+
await expect(employee.page.getByText('Cost', { exact: true })).toBeVisible();
186+
187+
// 1h at 100.00/h = 100.00 EUR cost should be visible
188+
await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible();
189+
});
119190
});

e2e/projects.spec.ts

Lines changed: 192 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,15 @@ test('test that updating billable rate works with existing time entries', async
124124

125125
await page.getByRole('row').first().getByRole('button').click();
126126
await page.getByRole('menuitem').getByText('Edit').first().click();
127-
await page.getByText('Non-Billable').click();
128-
await page.getByText('Custom Rate').click();
127+
128+
// Set billable default to Billable
129+
await page.getByRole('dialog').locator('#billable').click();
130+
await page.getByRole('option', { name: 'Billable', exact: true }).click();
131+
132+
// Set billable rate to Custom Rate
133+
await page.getByRole('dialog').locator('#billableRateType').click();
134+
await page.getByRole('option', { name: 'Custom Rate' }).click();
135+
129136
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
130137
await page.getByRole('button', { name: 'Update Project' }).click();
131138

@@ -153,6 +160,180 @@ test('test that updating billable rate works with existing time entries', async
153160
).toBeVisible();
154161
});
155162

163+
test('test that creating a project with default billable rate works', async ({ page }) => {
164+
const newProjectName = 'Default Rate Project ' + Math.floor(1 + Math.random() * 10000);
165+
await goToProjectsOverview(page);
166+
await page.getByRole('button', { name: 'Create Project' }).click();
167+
await page.getByLabel('Project Name').fill(newProjectName);
168+
169+
// Set billable default to Billable (leaves rate type as Default Rate)
170+
await page.getByRole('dialog').locator('#billable').click();
171+
await page.getByRole('option', { name: 'Billable', exact: true }).click();
172+
173+
// Verify rate type is "Default Rate" and the rate input is disabled
174+
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
175+
'Default Rate'
176+
);
177+
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
178+
179+
await Promise.all([
180+
page.getByRole('button', { name: 'Create Project' }).click(),
181+
page.waitForResponse(
182+
async (response) =>
183+
response.url().includes('/projects') &&
184+
response.request().method() === 'POST' &&
185+
response.status() === 201 &&
186+
(await response.json()).data.is_billable === true &&
187+
(await response.json()).data.billable_rate === null
188+
),
189+
]);
190+
191+
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
192+
});
193+
194+
test('test that creating a non-billable project works', async ({ page }) => {
195+
const newProjectName = 'Non-Billable Project ' + Math.floor(1 + Math.random() * 10000);
196+
await goToProjectsOverview(page);
197+
await page.getByRole('button', { name: 'Create Project' }).click();
198+
await page.getByLabel('Project Name').fill(newProjectName);
199+
200+
// Billable default should already be "Non-billable" by default
201+
await expect(page.getByRole('dialog').locator('#billable')).toContainText('Non-billable');
202+
203+
await Promise.all([
204+
page.getByRole('button', { name: 'Create Project' }).click(),
205+
page.waitForResponse(
206+
async (response) =>
207+
response.url().includes('/projects') &&
208+
response.request().method() === 'POST' &&
209+
response.status() === 201 &&
210+
(await response.json()).data.is_billable === false &&
211+
(await response.json()).data.billable_rate === null
212+
),
213+
]);
214+
215+
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
216+
});
217+
218+
test('test that switching from custom rate to default rate clears billable rate', async ({
219+
page,
220+
ctx,
221+
}) => {
222+
const newProjectName = 'Rate Switch Project ' + Math.floor(1 + Math.random() * 10000);
223+
// Create a project with an existing custom billable rate
224+
await createProjectViaApi(ctx, {
225+
name: newProjectName,
226+
is_billable: true,
227+
billable_rate: 15000,
228+
});
229+
230+
await goToProjectsOverview(page);
231+
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
232+
233+
await page.getByRole('row').first().getByRole('button').click();
234+
await page.getByRole('menuitem').getByText('Edit').first().click();
235+
236+
// Verify it loaded as Billable with Custom Rate
237+
await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');
238+
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
239+
'Custom Rate'
240+
);
241+
242+
// Switch to Default Rate
243+
await page.getByRole('dialog').locator('#billableRateType').click();
244+
await page.getByRole('option', { name: 'Default Rate' }).click();
245+
246+
// Rate input should now be disabled
247+
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
248+
249+
// Submit — billable_rate changes from 15000 to null, so confirmation dialog appears
250+
await page.getByRole('button', { name: 'Update Project' }).click();
251+
await Promise.all([
252+
page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),
253+
page.waitForResponse(
254+
async (response) =>
255+
response.url().includes('/projects/') &&
256+
response.request().method() === 'PUT' &&
257+
response.status() === 200 &&
258+
(await response.json()).data.is_billable === true &&
259+
(await response.json()).data.billable_rate === null
260+
),
261+
]);
262+
});
263+
264+
test('test that switching from billable to non-billable preserves rate settings', async ({
265+
page,
266+
ctx,
267+
}) => {
268+
const newProjectName = 'Billable Reset Project ' + Math.floor(1 + Math.random() * 10000);
269+
// Create a project with a custom billable rate
270+
await createProjectViaApi(ctx, {
271+
name: newProjectName,
272+
is_billable: true,
273+
billable_rate: 20000,
274+
});
275+
276+
await goToProjectsOverview(page);
277+
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
278+
279+
await page.getByRole('row').first().getByRole('button').click();
280+
await page.getByRole('menuitem').getByText('Edit').first().click();
281+
282+
// Verify it loaded correctly as Billable with Custom Rate
283+
await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');
284+
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
285+
'Custom Rate'
286+
);
287+
288+
// Switch to Non-billable
289+
await page.getByRole('dialog').locator('#billable').click();
290+
await page.getByRole('option', { name: 'Non-billable' }).click();
291+
292+
// Rate type should still be Custom Rate (not reset)
293+
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
294+
'Custom Rate'
295+
);
296+
297+
// Submit and verify project is non-billable but keeps its custom rate
298+
await Promise.all([
299+
page.getByRole('button', { name: 'Update Project' }).click(),
300+
page.waitForResponse(
301+
async (response) =>
302+
response.url().includes('/projects/') &&
303+
response.request().method() === 'PUT' &&
304+
response.status() === 200 &&
305+
(await response.json()).data.is_billable === false &&
306+
(await response.json()).data.billable_rate === 20000
307+
),
308+
]);
309+
});
310+
311+
test('test that editing an existing billable project with default rate loads correctly', async ({
312+
page,
313+
ctx,
314+
}) => {
315+
const newProjectName = 'Default Rate Edit Project ' + Math.floor(1 + Math.random() * 10000);
316+
// Create a project that is billable but has no custom rate (= default rate)
317+
await createProjectViaApi(ctx, {
318+
name: newProjectName,
319+
is_billable: true,
320+
billable_rate: null,
321+
});
322+
323+
await goToProjectsOverview(page);
324+
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
325+
326+
await page.getByRole('row').first().getByRole('button').click();
327+
await page.getByRole('menuitem').getByText('Edit').first().click();
328+
329+
// Verify it loaded as Billable with Default Rate
330+
await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');
331+
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
332+
'Default Rate'
333+
);
334+
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
335+
});
336+
156337
// Sorting tests
157338
test('test that sorting projects by name works', async ({ page }) => {
158339
await goToProjectsOverview(page);
@@ -296,8 +477,15 @@ test('test that custom billable rate is displayed correctly on project detail pa
296477
// Edit the project to set a custom billable rate
297478
await page.getByRole('row').first().getByRole('button').click();
298479
await page.getByRole('menuitem').getByText('Edit').first().click();
299-
await page.getByText('Non-Billable').click();
300-
await page.getByText('Custom Rate').click();
480+
481+
// Set billable default to Billable
482+
await page.getByRole('dialog').locator('#billable').click();
483+
await page.getByRole('option', { name: 'Billable', exact: true }).click();
484+
485+
// Set billable rate to Custom Rate
486+
await page.getByRole('dialog').locator('#billableRateType').click();
487+
await page.getByRole('option', { name: 'Custom Rate' }).click();
488+
301489
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
302490
await page.getByRole('button', { name: 'Update Project' }).click();
303491

e2e/tasks.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,69 @@ test('test that multiple tasks are displayed on project detail page', async ({ p
195195
await expect(page.getByText(taskName2)).toBeVisible();
196196
});
197197

198+
test('test that creating a new project from the task create modal project dropdown works', async ({
199+
page,
200+
ctx,
201+
}) => {
202+
const existingProjectName = 'Existing Project ' + Math.floor(1 + Math.random() * 10000);
203+
const newProjectName = 'Dropdown Created Project ' + Math.floor(1 + Math.random() * 10000);
204+
const newTaskName = 'Task With New Project ' + Math.floor(1 + Math.random() * 10000);
205+
206+
const project = await createProjectViaApi(ctx, { name: existingProjectName });
207+
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
208+
209+
// Open the Create Task modal
210+
await page.getByRole('button', { name: 'Create Task' }).click();
211+
await expect(page.getByRole('dialog')).toBeVisible();
212+
await page.getByPlaceholder('Task Name').fill(newTaskName);
213+
214+
// Open the project dropdown (it should show the current project)
215+
await page.getByRole('dialog').getByRole('button', { name: existingProjectName }).click();
216+
217+
// Click "Create new Project" at the bottom of the dropdown
218+
await page.getByText('Create new Project').click();
219+
220+
// The ProjectCreateModal should appear
221+
await expect(page.getByLabel('Project name')).toBeVisible();
222+
await page.getByLabel('Project name').fill(newProjectName);
223+
224+
// Submit the project creation
225+
await Promise.all([
226+
page.getByRole('button', { name: 'Create Project' }).click(),
227+
page.waitForResponse(
228+
async (response) =>
229+
response.url().includes('/projects') &&
230+
response.request().method() === 'POST' &&
231+
response.status() === 201 &&
232+
(await response.json()).data.name === newProjectName
233+
),
234+
]);
235+
236+
// The project dropdown trigger should now show the new project name
237+
await expect(
238+
page.getByRole('dialog').getByRole('button', { name: newProjectName })
239+
).toBeVisible();
240+
241+
// Submit the task and capture the response to get the new project ID
242+
const [taskResponse] = await Promise.all([
243+
page.waitForResponse(
244+
async (response) =>
245+
response.url().includes('/tasks') &&
246+
response.request().method() === 'POST' &&
247+
response.status() === 201 &&
248+
(await response.json()).data.name === newTaskName
249+
),
250+
page.getByRole('button', { name: 'Create Task' }).click(),
251+
]);
252+
253+
const taskData = await taskResponse.json();
254+
const newProjectId = taskData.data.project_id;
255+
256+
// Navigate to the new project's page and verify the task is there
257+
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + newProjectId);
258+
await expect(page.getByTestId('task_table')).toContainText(newTaskName);
259+
});
260+
198261
// =============================================
199262
// Employee Permission Tests
200263
// =============================================

0 commit comments

Comments
 (0)