mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
add break time entries and simplified time tracker ui
This commit is contained in:
278
e2e/breaks.spec.ts
Normal file
278
e2e/breaks.spec.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { expect, test } from '../playwright/fixtures';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
assertThatTimerHasStarted,
|
||||
assertThatTimerIsStopped,
|
||||
newTimeEntryResponse,
|
||||
startOrStopTimerWithButton,
|
||||
stoppedTimeEntryResponse,
|
||||
} from './utils/currentTimeEntry';
|
||||
import { createTimeEntryViaApi, updateOrganizationSettingViaApi } from './utils/api';
|
||||
|
||||
async function goToDashboard(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
}
|
||||
|
||||
function visibleBreakButton(page: Page) {
|
||||
return page.getByRole('button', { name: 'Take a break' }).locator('visible=true').first();
|
||||
}
|
||||
|
||||
// Breaks are disabled by default for new organizations, so enable them for the break flows.
|
||||
// The tests that assert the disabled behaviour turn them back off explicitly.
|
||||
test.beforeEach(async ({ ctx }) => {
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||||
});
|
||||
|
||||
test('test that switching to a break stops the work timer and starts a break entry', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
||||
await page.getByTestId('time_entry_description').fill('Work before break');
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
|
||||
page.getByTestId('time_entry_description').press('Enter'),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Switch to break: stops the work entry and starts a break entry
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page, { description: '', type: 'break' }),
|
||||
visibleBreakButton(page).click(),
|
||||
]);
|
||||
await expect(page.getByText('On break')).toBeVisible();
|
||||
|
||||
// The break bar offers a one-click resume that stops the break and restores
|
||||
// the interrupted work context
|
||||
await page.waitForTimeout(1500);
|
||||
const resumeButton = page.getByRole('button', { name: 'Resume "Work before break"' });
|
||||
await expect(resumeButton).toBeVisible();
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page, { type: 'break' }),
|
||||
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
|
||||
resumeButton.click(),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await expect(page.getByTestId('time_entry_description')).toHaveValue('Work before break');
|
||||
|
||||
// Cleanup: stop the running entry
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that stopping a break returns to an idle tracker where a fresh entry starts normally', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToDashboard(page);
|
||||
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
||||
await page.getByTestId('time_entry_description').fill('Work before break');
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
|
||||
page.getByTestId('time_entry_description').press('Enter'),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Switch to a break
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page, { description: '', type: 'break' }),
|
||||
visibleBreakButton(page).click(),
|
||||
]);
|
||||
await expect(page.getByText('On break')).toBeVisible();
|
||||
|
||||
// Stopping the break just ends it — no modal, the tracker returns to the
|
||||
// empty idle input with focus so typing starts a fresh entry
|
||||
await page.waitForTimeout(1500);
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page, { type: 'break' }),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
await expect(page.getByTestId('time_entry_description')).toHaveValue('');
|
||||
await expect(page.getByTestId('time_entry_description')).toBeFocused();
|
||||
|
||||
// A fresh entry is the normal start flow: type + Enter
|
||||
await page.getByTestId('time_entry_description').fill('Fresh after break');
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page, { description: 'Fresh after break', type: 'work' }),
|
||||
page.getByTestId('time_entry_description').press('Enter'),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
// Cleanup: stop the running entry
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page, { description: 'Fresh after break', type: 'work' }),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that the more options dropdown can start a break directly', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
||||
|
||||
// Start a break straight from the more options dropdown (no create modal)
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page, { description: '', type: 'break' }),
|
||||
page.getByRole('menuitem', { name: 'Start Break' }).click(),
|
||||
]);
|
||||
await expect(page.getByText('On break')).toBeVisible();
|
||||
|
||||
// Without interrupted work there is nothing to resume, so no resume button is offered
|
||||
await expect(page.getByRole('button', { name: /^Resume/ })).toHaveCount(0);
|
||||
|
||||
// Cleanup: stop the break
|
||||
await page.waitForTimeout(1500);
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page, { type: 'break' }),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that disabling breaks hides every break-creation entry point', async ({ page, ctx }) => {
|
||||
// Breaks disabled for the organization (delivered to the client via the organization endpoint)
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: false });
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Regular work' });
|
||||
|
||||
// Calendar: the empty-slot context menu offers "Create Time Entry" but no "Add Break",
|
||||
// and the edit modal drops the work-time/break type selector
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
||||
await expect(page.locator('.fc')).toBeVisible();
|
||||
const event = page.locator('.fc-event').filter({ hasText: 'Regular work' }).first();
|
||||
await event.scrollIntoViewIfNeeded();
|
||||
await expect(event).toBeVisible();
|
||||
|
||||
const box = await event.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height + 40, { button: 'right' });
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Create Time Entry' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Add Break' })).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await event.click({ button: 'right' });
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Work time' })
|
||||
).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Timesheet: no break row is shown
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
|
||||
await expect(page.getByRole('button', { name: 'Add row' }).first()).toBeVisible();
|
||||
await expect(page.getByText('Break', { exact: true })).toHaveCount(0);
|
||||
|
||||
// Dashboard tracker: no "Start Break" in the more options dropdown
|
||||
await goToDashboard(page);
|
||||
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Switch to simple mode' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Start Break' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
// The employee fixture registers a second user and accepts an invitation via Mailpit,
|
||||
// which does not fit into the default per-test timeout.
|
||||
test.describe('Org-level breaks setting', () => {
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
|
||||
test('test that the org-level breaks setting is respected for employees', async ({
|
||||
ctx,
|
||||
employee,
|
||||
}) => {
|
||||
const employeePage = employee.page;
|
||||
|
||||
// Breaks enabled (via beforeEach): the employee sees "Start Break" in the more options dropdown
|
||||
await employeePage.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
await expect(employeePage.getByTestId('dashboard_view')).toBeVisible();
|
||||
await employeePage.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await expect(
|
||||
employeePage.getByRole('menuitem', { name: 'Switch to simple mode' })
|
||||
).toBeVisible();
|
||||
await expect(employeePage.getByRole('menuitem', { name: 'Start Break' })).toBeVisible();
|
||||
await employeePage.keyboard.press('Escape');
|
||||
|
||||
// The owner disables breaks for the whole organization
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: false });
|
||||
|
||||
// The employee reloads: "Start Break" is gone from the dropdown
|
||||
await employeePage.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
await expect(employeePage.getByTestId('dashboard_view')).toBeVisible();
|
||||
await employeePage.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await expect(
|
||||
employeePage.getByRole('menuitem', { name: 'Switch to simple mode' })
|
||||
).toBeVisible();
|
||||
await expect(employeePage.getByRole('menuitem', { name: 'Start Break' })).toHaveCount(0);
|
||||
await employeePage.keyboard.press('Escape');
|
||||
|
||||
// With an active timer the break (coffee) button is not shown either
|
||||
await employeePage.getByTestId('time_entry_description').fill('Employee work');
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(employeePage, { description: 'Employee work', type: 'work' }),
|
||||
employeePage.getByTestId('time_entry_description').press('Enter'),
|
||||
]);
|
||||
await assertThatTimerHasStarted(employeePage);
|
||||
await expect(employeePage.getByRole('button', { name: 'Take a break' })).toHaveCount(0);
|
||||
|
||||
// Cleanup: stop the running entry
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(employeePage, { description: 'Employee work', type: 'work' }),
|
||||
startOrStopTimerWithButton(employeePage),
|
||||
]);
|
||||
await assertThatTimerIsStopped(employeePage);
|
||||
});
|
||||
});
|
||||
|
||||
test('test that mass update warns about selected breaks and reports skipped entries instead of success', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// One work entry and one break: a billable mass update applies to the work
|
||||
// entry but the server skips the break entirely — the UI must say so.
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Mass update work entry' });
|
||||
await createTimeEntryViaApi(ctx, { duration: '30min', type: 'break' });
|
||||
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await expect(page.locator('[data-testid="time_entry_row"]')).toHaveCount(2);
|
||||
await page.getByLabel('Select All').click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// No warning while the changeset is compatible with breaks
|
||||
await expect(page.getByTestId('mass_update_break_warning')).not.toBeVisible();
|
||||
|
||||
// Making the entries billable is break-incompatible → warning appears
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('combobox')
|
||||
.filter({ hasText: 'Set billable status' })
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
await expect(page.getByTestId('mass_update_break_warning')).toBeVisible();
|
||||
await expect(page.getByTestId('mass_update_break_warning')).toContainText('skipped entirely');
|
||||
|
||||
// Submit: the work entry updates, the break is skipped, and the toast
|
||||
// reports the skip instead of claiming success for all entries
|
||||
const [massUpdateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'PATCH' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('button', { name: 'Update Time Entries' }).click(),
|
||||
]);
|
||||
const massUpdateBody = await massUpdateResponse.json();
|
||||
expect(massUpdateBody.success.length).toBe(1);
|
||||
expect(massUpdateBody.error.length).toBe(1);
|
||||
await expect(page.getByText('1 of 2 time entries was skipped')).toBeVisible();
|
||||
});
|
||||
@@ -2874,3 +2874,54 @@ test.describe('Daily Total After Create', () => {
|
||||
}).toPass({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('test that calendar context menu can add a break that fills the gap between two entries', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||||
// Two work entries today (09:00-10:00 and 11:00-12:00 UTC) with a one hour gap
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const gapStart = `${today}T10:00:00Z`;
|
||||
const gapEnd = `${today}T11:00:00Z`;
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${today}T09:00:00Z`,
|
||||
end: gapStart,
|
||||
description: 'Gap work A',
|
||||
});
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: gapEnd,
|
||||
end: `${today}T12:00:00Z`,
|
||||
description: 'Gap work B',
|
||||
});
|
||||
|
||||
await goToCalendar(page);
|
||||
const eventA = page.locator('.fc-event').filter({ hasText: 'Gap work A' }).first();
|
||||
await eventA.scrollIntoViewIfNeeded();
|
||||
await expect(eventA).toBeVisible();
|
||||
|
||||
// Right-click just below entry A (inside the gap, in the same day column)
|
||||
const box = await eventA.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height + 15, {
|
||||
button: 'right',
|
||||
});
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Add Break' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// The break is prefilled to fill the gap exactly
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
async (response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201 &&
|
||||
(await response.json()).data.type === 'break'
|
||||
),
|
||||
page.getByRole('button', { name: 'Add Break' }).click(),
|
||||
]);
|
||||
const body = await createResponse.json();
|
||||
expect(body.data.start).toBe(gapStart);
|
||||
expect(body.data.end).toBe(gapEnd);
|
||||
});
|
||||
|
||||
@@ -1019,3 +1019,24 @@ test.describe('Employee Reporting Restrictions', () => {
|
||||
await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('test that reporting has a type filter that can show only breaks', async ({ page, ctx }) => {
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Regular work entry' });
|
||||
await createTimeEntryViaApi(ctx, { duration: '20min', type: 'break' });
|
||||
|
||||
await goToReporting(page);
|
||||
// The type filter defaults to "Work time"; switching it to "Breaks" re-aggregates.
|
||||
const typeFilter = page.getByRole('combobox').filter({ hasText: 'Work time' });
|
||||
await expect(typeFilter).toBeVisible();
|
||||
await typeFilter.click();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') &&
|
||||
response.url().includes('type=break') &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('option', { name: 'Breaks' }).click(),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2303,3 +2303,19 @@ test('test that aggregate row context menu delete removes all grouped entries',
|
||||
page.locator('[data-testid="time_entry_row"]').filter({ hasText: description })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that break entries show a break badge and split day total on the time page', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||||
await createTimeEntryViaApi(ctx, { duration: '2h', description: 'Some work' });
|
||||
await createTimeEntryViaApi(ctx, { duration: '30min', type: 'break', description: '' });
|
||||
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await expect(page.getByTestId('break_badge').first()).toBeVisible();
|
||||
await expect(page.getByTestId('break_badge').first()).toContainText('Break');
|
||||
// Day heading shows the break portion separately from worked time
|
||||
await expect(page.getByTestId('day_break_duration').first()).toBeVisible();
|
||||
await expect(page.getByTestId('day_break_duration').first()).toContainText('break');
|
||||
});
|
||||
|
||||
@@ -2,7 +2,14 @@ import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { createProjectViaApi, createTaskViaApi, createTimeEntryOnDateViaApi } from './utils/api';
|
||||
import {
|
||||
createProjectViaApi,
|
||||
createTaskViaApi,
|
||||
createTimeEntryOnDateViaApi,
|
||||
createTimeEntryWithTimestampsViaApi,
|
||||
getTimeEntriesViaApi,
|
||||
updateOrganizationSettingViaApi,
|
||||
} from './utils/api';
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Helpers
|
||||
@@ -639,3 +646,279 @@ test('cell accepts various duration input formats', async ({ page, ctx }) => {
|
||||
// 1.5 hours = 1h 30min
|
||||
await expect(mondayInput).toHaveValue('1h 30min');
|
||||
});
|
||||
|
||||
test('test that adding a timesheet break to a full day splits the work entry via the placement modal', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// A single work entry filling the day leaves no gap for a break, so the placement
|
||||
// modal must offer to split it (the only entry) and drop the break in the middle.
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||||
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T09:00:00Z`,
|
||||
end: `${day}T17:00:00Z`,
|
||||
description: 'Split me',
|
||||
});
|
||||
|
||||
await goToTimesheet(page);
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
|
||||
// The break row is always present — enter a 30m break on Monday
|
||||
const breakRow = page
|
||||
.locator('[data-testid="timesheet_row"]')
|
||||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||||
await breakCell.click();
|
||||
await breakCell.fill('0.5');
|
||||
await breakCell.press('Enter');
|
||||
|
||||
// The placement modal opens with the split preview
|
||||
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
async (resp) =>
|
||||
resp.url().includes('/time-entries') &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201 &&
|
||||
(await resp.json()).data.type === 'break'
|
||||
),
|
||||
page.getByRole('button', { name: 'Add break' }).click(),
|
||||
]);
|
||||
|
||||
// The day now has two work halves and one break, none overlapping
|
||||
const entries = await getTimeEntriesViaApi(ctx);
|
||||
const dayEntries = entries
|
||||
.filter((e) => e.start.startsWith(day))
|
||||
.sort((a, b) => a.start.localeCompare(b.start));
|
||||
expect(dayEntries).toHaveLength(3);
|
||||
expect(dayEntries.map((e) => e.type)).toEqual(['work', 'break', 'work']);
|
||||
// The break sits flush between the two halves
|
||||
expect(dayEntries[0].end).toBe(dayEntries[1].start);
|
||||
expect(dayEntries[1].end).toBe(dayEntries[2].start);
|
||||
});
|
||||
|
||||
test('test that adding a break into an oversized gap places it without moving other entries', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// 09-12 and 15-17 leave a 3h gap — wider than the placement tolerance allows,
|
||||
// but easily big enough to hold the break. Such a gap is deliberate (the app
|
||||
// itself never creates one), so the break goes flush after the morning entry
|
||||
// and nothing else moves — no placement modal.
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||||
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T09:00:00Z`,
|
||||
end: `${day}T12:00:00Z`,
|
||||
description: 'Morning',
|
||||
});
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T15:00:00Z`,
|
||||
end: `${day}T17:00:00Z`,
|
||||
description: 'Afternoon',
|
||||
});
|
||||
|
||||
await goToTimesheet(page);
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
|
||||
const breakRow = page
|
||||
.locator('[data-testid="timesheet_row"]')
|
||||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||||
await breakCell.click();
|
||||
await breakCell.fill('0.5');
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
async (resp) =>
|
||||
resp.url().includes('/time-entries') &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201 &&
|
||||
(await resp.json()).data.type === 'break'
|
||||
),
|
||||
breakCell.press('Enter'),
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('break_placement_summary')).not.toBeVisible();
|
||||
const entries = await getTimeEntriesViaApi(ctx);
|
||||
const dayEntries = entries
|
||||
.filter((e) => e.start.startsWith(day))
|
||||
.sort((a, b) => a.start.localeCompare(b.start));
|
||||
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
|
||||
['work', `${day}T09:00:00Z`, `${day}T12:00:00Z`],
|
||||
['break', `${day}T12:00:00Z`, `${day}T12:30:00Z`],
|
||||
['work', `${day}T15:00:00Z`, `${day}T17:00:00Z`],
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that the placement modal warns when the chosen time would leave the break misaligned', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Back-to-back 09-12 and 12-17 leave no gap, so the placement modal opens.
|
||||
// The suggested slot (flush at 12:00) is aligned — no warning. Moving the
|
||||
// break to 07:00, before any work, keeps the plan feasible but the result
|
||||
// would immediately carry the misaligned hint, so the modal warns upfront.
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||||
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T09:00:00Z`,
|
||||
end: `${day}T12:00:00Z`,
|
||||
description: 'Morning',
|
||||
});
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T12:00:00Z`,
|
||||
end: `${day}T17:00:00Z`,
|
||||
description: 'Afternoon',
|
||||
});
|
||||
|
||||
await goToTimesheet(page);
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
|
||||
const breakRow = page
|
||||
.locator('[data-testid="timesheet_row"]')
|
||||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||||
await breakCell.click();
|
||||
await breakCell.fill('0.5');
|
||||
await breakCell.press('Enter');
|
||||
|
||||
// Default suggestion sits flush between work → no warning
|
||||
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
|
||||
await expect(page.getByTestId('break_placement_misaligned_warning')).not.toBeVisible();
|
||||
|
||||
// Move the break to 07:00-07:30, before all work
|
||||
const modal = page.getByRole('dialog');
|
||||
const startTimeInput = modal.getByTestId('time_picker_input').first();
|
||||
await startTimeInput.fill('07:00');
|
||||
await startTimeInput.press('Tab');
|
||||
const endTimeInput = modal.getByTestId('time_picker_input').nth(1);
|
||||
await endTimeInput.fill('07:30');
|
||||
await endTimeInput.press('Tab');
|
||||
|
||||
// Feasible (nothing has to move), but flagged as misaligned beforehand
|
||||
await expect(page.getByTestId('break_placement_misaligned_warning')).toBeVisible();
|
||||
await expect(page.getByTestId('break_placement_summary')).toContainText(
|
||||
'No entries need to move.'
|
||||
);
|
||||
|
||||
// The warning is non-blocking: the break can still be added as chosen
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
async (resp) =>
|
||||
resp.url().includes('/time-entries') &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201 &&
|
||||
(await resp.json()).data.type === 'break'
|
||||
),
|
||||
page.getByRole('button', { name: 'Add break' }).click(),
|
||||
]);
|
||||
|
||||
const entries = await getTimeEntriesViaApi(ctx);
|
||||
const dayEntries = entries
|
||||
.filter((e) => e.start.startsWith(day))
|
||||
.sort((a, b) => a.start.localeCompare(b.start));
|
||||
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
|
||||
['break', `${day}T07:00:00Z`, `${day}T07:30:00Z`],
|
||||
['work', `${day}T09:00:00Z`, `${day}T12:00:00Z`],
|
||||
['work', `${day}T12:00:00Z`, `${day}T17:00:00Z`],
|
||||
]);
|
||||
// ...and the timesheet now shows the misaligned-break hint for that day
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'does not align with your work entries' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that a misplaced break shows a warning on its timesheet day cell', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Work ends at 10:00 and the break starts hours later with no work after it,
|
||||
// so it is misplaced and its day header should carry the warning hint.
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||||
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T09:00:00Z`,
|
||||
end: `${day}T10:00:00Z`,
|
||||
});
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T14:00:00Z`,
|
||||
end: `${day}T14:30:00Z`,
|
||||
type: 'break',
|
||||
});
|
||||
|
||||
await goToTimesheet(page);
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
|
||||
// Exactly one warning, sitting in Monday's day header
|
||||
const hint = page.getByRole('button', {
|
||||
name: 'does not align with your work entries',
|
||||
});
|
||||
await expect(hint).toHaveCount(1);
|
||||
await expect(
|
||||
page.getByTestId('timesheet_day_header').first().getByRole('button', {
|
||||
name: 'does not align with your work entries',
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
// The hint links to the calendar on the affected date
|
||||
await hint.click();
|
||||
await expect(page.getByRole('link', { name: 'Fix in calendar' })).toHaveAttribute(
|
||||
'href',
|
||||
`/calendar?date=${day}`
|
||||
);
|
||||
});
|
||||
|
||||
test('test that editing a timesheet break re-places it as one entry instead of fragmenting it', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Two work entries with a 1h gap, and a 30m break created directly inside it (12:15–12:45).
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||||
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T09:00:00Z`,
|
||||
end: `${day}T12:00:00Z`,
|
||||
description: 'Work',
|
||||
});
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T13:00:00Z`,
|
||||
end: `${day}T17:00:00Z`,
|
||||
description: 'Work',
|
||||
});
|
||||
const breakEntry = await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T12:15:00Z`,
|
||||
end: `${day}T12:45:00Z`,
|
||||
type: 'break',
|
||||
});
|
||||
|
||||
await goToTimesheet(page);
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
const breakRow = page
|
||||
.locator('[data-testid="timesheet_row"]')
|
||||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||||
await breakCell.click();
|
||||
await breakCell.fill('0.75'); // 45 minutes — still fits the 1h gap, so it stays anchored
|
||||
await Promise.all([
|
||||
// A break that still fits its gap is re-placed in place (PUT on the same entry),
|
||||
// not deleted and recreated — that's what keeps it a single entry.
|
||||
page.waitForResponse(
|
||||
async (resp) =>
|
||||
resp.url().includes(`/time-entries/${breakEntry.id}`) &&
|
||||
resp.request().method() === 'PUT' &&
|
||||
resp.status() === 200 &&
|
||||
(await resp.json()).data.type === 'break'
|
||||
),
|
||||
breakCell.press('Enter'),
|
||||
]);
|
||||
|
||||
// Still exactly one break on the day (not fragmented). It stays anchored at its current
|
||||
// start (12:15) rather than re-centering, growing its end to 13:00 to reach 45 minutes.
|
||||
const after = await getTimeEntriesViaApi(ctx);
|
||||
const breaks = after.filter((e) => e.start.startsWith(day) && e.type === 'break');
|
||||
expect(breaks).toHaveLength(1);
|
||||
expect(breaks[0].duration).toBe(2700);
|
||||
expect(breaks[0].start).toBe(`${day}T12:15:00Z`);
|
||||
expect(breaks[0].end).toBe(`${day}T13:00:00Z`);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
createProjectViaApi,
|
||||
createTaskViaApi,
|
||||
createClientViaApi,
|
||||
createTimeEntryViaApi,
|
||||
archiveProjectViaApi,
|
||||
markTaskDoneViaApi,
|
||||
updateOrganizationCurrencyViaWeb,
|
||||
@@ -375,6 +376,66 @@ test('test that timer started on dashboard is visible on time page', async ({ pa
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that picking a recently tracked entry starts a timer with its fields', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const project = await createProjectViaApi(ctx, {
|
||||
name: `RecentProj ${Math.floor(Math.random() * 100000)}`,
|
||||
is_billable: false,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description: 'Recent work item',
|
||||
duration: '1h',
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
await goToDashboard(page);
|
||||
const description = page.getByTestId('time_entry_description');
|
||||
await expect(description).toBeEditable();
|
||||
|
||||
// Focusing the description opens the "Recently Tracked" dropdown listing the finished entry.
|
||||
await description.click();
|
||||
const recentEntry = page.getByText('Recent work item').first();
|
||||
await expect(recentEntry).toBeVisible();
|
||||
|
||||
// Clicking it (mousedown) copies its fields — including the project — into a new running entry.
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
if (
|
||||
!response.url().includes('/time-entries') ||
|
||||
response.request().method() !== 'POST' ||
|
||||
response.status() !== 201
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const body = await response.json();
|
||||
return (
|
||||
body.data.description === 'Recent work item' &&
|
||||
body.data.project_id === project.id &&
|
||||
body.data.end === null
|
||||
);
|
||||
}),
|
||||
recentEntry.click(),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await expect(description).toHaveValue('Recent work item');
|
||||
await expect(page.getByRole('button', { name: project.name })).toBeVisible();
|
||||
|
||||
// Cleanup: stop the running (project-bearing) entry
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
if (response.status() !== 200 || !response.url().includes('/time-entries/')) {
|
||||
return false;
|
||||
}
|
||||
const body = await response.json();
|
||||
return body.data.description === 'Recent work item' && body.data.end !== null;
|
||||
}),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that creating a new project from the time tracker dropdown prefills the search text', async ({
|
||||
page,
|
||||
ctx,
|
||||
@@ -681,3 +742,39 @@ test.describe('Project Task Dropdown', () => {
|
||||
await expect(page.getByRole('button', { name: projectName })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('test that simple mode hides the project, tag and billable controls', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
||||
// Project mode shows the project and billable controls
|
||||
await expect(page.getByRole('button', { name: 'No Project' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Non Billable' }).first()).toBeVisible();
|
||||
|
||||
// Switch to simple mode via the more options dropdown (client-side preference, no request)
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Switch to simple mode' }).click();
|
||||
|
||||
// Simple mode is the project tracker without the project/tag/billable selectors; the
|
||||
// description input and clock-in/out stay.
|
||||
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
||||
await expect(page.getByRole('button', { name: 'No Project' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Non Billable' })).toHaveCount(0);
|
||||
|
||||
// Clock in and out
|
||||
await Promise.all([
|
||||
newTimeEntryResponse(page, { type: 'work' }),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(1500);
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page, { type: 'work' }),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
|
||||
// Switch back to project mode: the controls return
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Switch to project mode' }).click();
|
||||
await expect(page.getByRole('button', { name: 'No Project' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -406,6 +406,7 @@ export async function createTimeEntryViaApi(
|
||||
taskId?: string | null;
|
||||
tags?: string[];
|
||||
billable?: boolean;
|
||||
type?: 'work' | 'break';
|
||||
}
|
||||
) {
|
||||
const { start, end } = createTimestamps(data.duration);
|
||||
@@ -421,6 +422,7 @@ export async function createTimeEntryViaApi(
|
||||
task_id: data.taskId ?? null,
|
||||
tags: data.tags ?? [],
|
||||
billable: data.billable ?? false,
|
||||
type: data.type ?? 'work',
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -754,6 +756,7 @@ export async function getTimeEntriesViaApi(
|
||||
project_id: string | null;
|
||||
task_id: string | null;
|
||||
description: string;
|
||||
type: 'work' | 'break';
|
||||
}>
|
||||
> {
|
||||
const params = new URLSearchParams();
|
||||
@@ -779,6 +782,7 @@ export async function createTimeEntryWithTimestampsViaApi(
|
||||
taskId?: string | null;
|
||||
tags?: string[];
|
||||
billable?: boolean;
|
||||
type?: 'work' | 'break';
|
||||
}
|
||||
) {
|
||||
const response = await ctx.request.post(
|
||||
@@ -793,12 +797,19 @@ export async function createTimeEntryWithTimestampsViaApi(
|
||||
task_id: data.taskId ?? null,
|
||||
tags: data.tags ?? [],
|
||||
billable: data.billable ?? false,
|
||||
type: data.type ?? 'work',
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(response.status()).toBe(201);
|
||||
const body = await response.json();
|
||||
return body.data as { id: string; start: string; end: string; description: string };
|
||||
return body.data as {
|
||||
id: string;
|
||||
start: string;
|
||||
end: string;
|
||||
description: string;
|
||||
type: 'work' | 'break';
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -903,3 +914,71 @@ export async function createReportViaApi(
|
||||
public_until: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Invoices
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function createInvoiceViaApi(
|
||||
ctx: TestContext,
|
||||
data: {
|
||||
reference: string;
|
||||
buyer_name?: string;
|
||||
seller_name?: string;
|
||||
currency?: string;
|
||||
date?: string;
|
||||
tax_rate?: number;
|
||||
}
|
||||
) {
|
||||
const response = await ctx.request.post(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoices`,
|
||||
{
|
||||
data: {
|
||||
seller_name: data.seller_name ?? 'Test Seller',
|
||||
buyer_name: data.buyer_name ?? 'Test Buyer',
|
||||
reference: data.reference,
|
||||
currency: data.currency ?? 'EUR',
|
||||
date: data.date ?? new Date().toISOString().split('T')[0],
|
||||
// Mirror the UI create form, which always sends a tax rate (default 0).
|
||||
// Invoices with a null tax_rate currently crash PDF rendering.
|
||||
tax_rate: data.tax_rate ?? 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(response.status()).toBe(201);
|
||||
const body = await response.json();
|
||||
return body.data as { id: string; reference: string; buyer_name: string };
|
||||
}
|
||||
|
||||
export async function updateInvoiceSettingsViaApi(ctx: TestContext, data: Record<string, unknown>) {
|
||||
const response = await ctx.request.put(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoice-settings`,
|
||||
{ data }
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
const body = await response.json();
|
||||
return body.data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function getInvoiceSettingsViaApi(ctx: TestContext) {
|
||||
const response = await ctx.request.get(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoice-settings`
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
const body = await response.json();
|
||||
return body.data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function getInvoicesViaApi(ctx: TestContext) {
|
||||
const response = await ctx.request.get(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoices`
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
const body = await response.json();
|
||||
return body.data as Array<{
|
||||
id: string;
|
||||
reference: string;
|
||||
buyer_name: string;
|
||||
paid_date: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,17 @@ export async function assertThatTimerHasStarted(page: Page) {
|
||||
|
||||
export function newTimeEntryResponse(
|
||||
page: Page,
|
||||
{ description = '', status = 201, tags = [] } = {}
|
||||
{
|
||||
description = '',
|
||||
status = 201,
|
||||
tags = [],
|
||||
type,
|
||||
}: {
|
||||
description?: string;
|
||||
status?: number;
|
||||
tags?: string[];
|
||||
type?: 'work' | 'break';
|
||||
} = {}
|
||||
) {
|
||||
return page.waitForResponse(async (response) => {
|
||||
return (
|
||||
@@ -34,6 +44,7 @@ export function newTimeEntryResponse(
|
||||
(await response.json()).data.description === description &&
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
(type === undefined || (await response.json()).data.type === type) &&
|
||||
JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags)
|
||||
);
|
||||
});
|
||||
@@ -48,7 +59,18 @@ export async function assertThatTimerIsStopped(page: Page) {
|
||||
).toHaveClass(/bg-accent-300\/70/);
|
||||
}
|
||||
|
||||
export async function stoppedTimeEntryResponse(page: Page, { description = '', tags = [] } = {}) {
|
||||
export async function stoppedTimeEntryResponse(
|
||||
page: Page,
|
||||
{
|
||||
description = '',
|
||||
tags = [],
|
||||
type,
|
||||
}: {
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
type?: 'work' | 'break';
|
||||
} = {}
|
||||
) {
|
||||
return page.waitForResponse(async (response) => {
|
||||
return (
|
||||
response.status() === 200 &&
|
||||
@@ -62,6 +84,7 @@ export async function stoppedTimeEntryResponse(page: Page, { description = '', t
|
||||
(await response.json()).data.task_id === null &&
|
||||
(await response.json()).data.duration !== null &&
|
||||
(await response.json()).data.user_id !== null &&
|
||||
(type === undefined || (await response.json()).data.type === type) &&
|
||||
JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags)
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user