mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
Add reporting e2e helpers and detailed tests
This commit is contained in:
@@ -3,12 +3,16 @@
|
||||
// TODO: Remove Invitation
|
||||
import { expect, test } from '../playwright/fixtures';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import type { Page } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
|
||||
async function goToMembersPage(page) {
|
||||
async function goToMembersPage(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||
}
|
||||
|
||||
async function openInviteMemberModal(page) {
|
||||
async function openInviteMemberModal(page: Page) {
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Invite Member' }).click(),
|
||||
expect(page.getByPlaceholder('Member Email')).toBeVisible(),
|
||||
@@ -35,7 +39,7 @@ test('test that new employee can be invited', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Employee' }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||
await expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
|
||||
expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -91,3 +95,123 @@ test('test that organization billable rate can be updated with all existing time
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
async function createPlaceholderMemberViaImport(page: Page, placeholderName: string) {
|
||||
const placeholderEmail = `placeholder+${Math.floor(Math.random() * 100000)}@solidtime-import.test`;
|
||||
const csvContent = [
|
||||
'User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Tags',
|
||||
`${placeholderName},${placeholderEmail},,,,Imported entry,No,2024-01-01,09:00:00,2024-01-01,10:00:00,`,
|
||||
].join('\n');
|
||||
|
||||
// Write CSV to a temp file for upload
|
||||
const tmpDir = os.tmpdir();
|
||||
const tmpFile = path.join(tmpDir, `import-${Date.now()}.csv`);
|
||||
fs.writeFileSync(tmpFile, csvContent);
|
||||
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/import');
|
||||
|
||||
// Select "Toggl Time Entries" import type
|
||||
await page.locator('select#importType').selectOption({ label: 'Toggl Time Entries' });
|
||||
|
||||
// Upload the CSV file
|
||||
await page.locator('input[type="file"]').setInputFiles(tmpFile);
|
||||
|
||||
// Click Import and wait for success
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Import Data' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/import') && response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
// Close the result modal
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// Clean up temp file
|
||||
fs.unlinkSync(tmpFile);
|
||||
}
|
||||
|
||||
test('test that changing member role updates the role in the member table', async ({ page }) => {
|
||||
const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create a placeholder member via import
|
||||
await createPlaceholderMemberViaImport(page, placeholderName);
|
||||
|
||||
// Go to members page and verify placeholder exists with role "Placeholder"
|
||||
await goToMembersPage(page);
|
||||
const memberRow = page.getByRole('row').filter({ hasText: placeholderName });
|
||||
await expect(memberRow).toBeVisible();
|
||||
await expect(memberRow.getByText('Placeholder')).toBeVisible();
|
||||
|
||||
// Open the edit modal for the placeholder member
|
||||
await memberRow.getByRole('button').click();
|
||||
await page.getByRole('menuitem').getByText('Edit').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
||||
|
||||
// Change role to Employee
|
||||
const roleSelect = page.getByRole('dialog').getByRole('combobox').first();
|
||||
await roleSelect.click();
|
||||
await page.getByRole('option', { name: 'Employee' }).click();
|
||||
|
||||
// Submit the change and verify the API call succeeds
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Update Member' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/members/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
// Verify dialog closed
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
|
||||
// Verify the role updated in the table
|
||||
await expect(memberRow.getByText('Employee')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that merging a placeholder member works', async ({ page }) => {
|
||||
const placeholderName = 'Merge Target ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create a placeholder member via import
|
||||
await createPlaceholderMemberViaImport(page, placeholderName);
|
||||
|
||||
// Go to members page
|
||||
await goToMembersPage(page);
|
||||
await expect(page.getByText(placeholderName)).toBeVisible();
|
||||
|
||||
// Find the placeholder member row and open actions menu
|
||||
const placeholderRow = page.getByRole('row').filter({ hasText: placeholderName });
|
||||
await placeholderRow.getByRole('button').click();
|
||||
|
||||
// Click Merge
|
||||
await page.getByTestId('member_merge').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible();
|
||||
|
||||
// Select the current user (the owner) as merge target via MemberCombobox
|
||||
const combobox = page.getByRole('dialog').getByRole('combobox');
|
||||
await combobox.click();
|
||||
|
||||
// Wait for dropdown options to load
|
||||
const firstOption = page.getByRole('option').first();
|
||||
await expect(firstOption).toBeVisible({ timeout: 10000 });
|
||||
await firstOption.click();
|
||||
|
||||
// Submit merge
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Merge Member' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/member/') && response.url().includes('/merge-into') && response.ok()
|
||||
),
|
||||
]);
|
||||
|
||||
// Wait for dialog to close after successful merge
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
|
||||
// Verify placeholder member is no longer in the members table
|
||||
await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible();
|
||||
});
|
||||
|
||||
@@ -32,8 +32,8 @@ test('test that updating project member billable rate works for existing time en
|
||||
await page.getByRole('button', { name: 'Add Member' }).click();
|
||||
|
||||
await expect(page.getByText('Add Project Member').first()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Select a member' }).click();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Select a member' }).click();
|
||||
await page.getByRole('option').first().click();
|
||||
await page.getByRole('button', { name: 'Add Project Member' }).click();
|
||||
|
||||
await page
|
||||
|
||||
598
e2e/reporting-detailed.spec.ts
Normal file
598
e2e/reporting-detailed.spec.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import {
|
||||
goToReportingDetailed,
|
||||
createProject,
|
||||
createClient,
|
||||
createProjectWithClient,
|
||||
createTask,
|
||||
createTimeEntryWithProject,
|
||||
createTimeEntryWithProjectAndTask,
|
||||
createTimeEntryWithTag,
|
||||
createBareTimeEntry,
|
||||
waitForDetailedReportingUpdate,
|
||||
} from './utils/reporting';
|
||||
|
||||
// Each test registers a new user and creates test data, which needs more time
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Basic Detailed View Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that detailed view shows time entries correctly', async ({ page }) => {
|
||||
const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
// Go to detailed reporting view
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
// Verify the time entry is shown with all details
|
||||
await expect(page.getByText(projectName, { exact: true })).toBeVisible();
|
||||
await expect(page.locator('input[name="Duration"]')).toHaveValue('1h 00min');
|
||||
await expect(page.getByText('Entry for ' + projectName, { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that updating duration in detailed view works correctly', async ({ page }) => {
|
||||
const projectName = 'Duration Update Project ' + Math.floor(Math.random() * 10000);
|
||||
const initialDuration = '1h';
|
||||
const updatedDuration = '2h 30min';
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, initialDuration);
|
||||
|
||||
// Go to detailed reporting view
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
// Find and update the duration
|
||||
const durationInput = page.locator('input[name="Duration"]').first();
|
||||
await durationInput.click();
|
||||
await durationInput.fill(updatedDuration);
|
||||
await Promise.all([
|
||||
durationInput.press('Enter'),
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
// Verify the new duration is displayed
|
||||
await expect(durationInput).toHaveValue(updatedDuration);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Project Filter Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that project multiselect filters work on detailed reporting page', async ({ page }) => {
|
||||
const project1 = 'DetailProj1 ' + Math.floor(Math.random() * 10000);
|
||||
const project2 = 'DetailProj2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createProject(page, project2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryWithProject(page, project2, '2h');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
// Wait for initial data load
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project2}`)).toBeVisible();
|
||||
|
||||
// Open project multiselect and select project1
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: project1 }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
// Verify only project1 entry is shown
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project2}`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Client Filter Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that client multiselect filters work on detailed reporting page', async ({ page }) => {
|
||||
const client1 = 'DetailClient1 ' + Math.floor(Math.random() * 10000);
|
||||
const project1 = 'DetailClientProj1 ' + Math.floor(Math.random() * 10000);
|
||||
const project2 = 'DetailClientProj2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createClient(page, client1);
|
||||
await createProjectWithClient(page, project1, client1);
|
||||
await createProject(page, project2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryWithProject(page, project2, '2h');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project2}`)).toBeVisible();
|
||||
|
||||
// Filter by client1
|
||||
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: client1 }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
// Only entries for project1 (with client1) should be visible
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project2}`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Task Filter Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that task multiselect dropdown filters reporting by task', async ({ page }) => {
|
||||
const projectName = 'TaskFilterProj ' + Math.floor(Math.random() * 10000);
|
||||
const task1 = 'TaskFilter1 ' + Math.floor(Math.random() * 10000);
|
||||
const task2 = 'TaskFilter2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTask(page, projectName, task1);
|
||||
await createTask(page, projectName, task2);
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h');
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task2, '2h');
|
||||
|
||||
// Use the detailed view to verify task filtering (shows individual entries)
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task2}`)).toBeVisible();
|
||||
|
||||
// Open task multiselect dropdown
|
||||
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||
|
||||
// Verify both tasks appear
|
||||
await expect(page.getByRole('option').filter({ hasText: task1 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: task2 })).toBeVisible();
|
||||
|
||||
// Select task1
|
||||
await page.getByRole('option').filter({ hasText: task1 }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
// Verify badge shows count of 1
|
||||
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();
|
||||
|
||||
// Verify only task1 entry is shown
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task2}`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that selecting multiple tasks shows correct badge count', async ({ page }) => {
|
||||
const projectName = 'MultiTaskProj ' + Math.floor(Math.random() * 10000);
|
||||
const task1 = 'MultiTask1 ' + Math.floor(Math.random() * 10000);
|
||||
const task2 = 'MultiTask2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTask(page, projectName, task1);
|
||||
await createTask(page, projectName, task2);
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h');
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task2, '2h');
|
||||
|
||||
// Use the detailed view to verify task filtering
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task2}`)).toBeVisible();
|
||||
|
||||
// Select both tasks
|
||||
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: task1 }).click();
|
||||
await page.getByRole('option').filter({ hasText: task2 }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
// Verify badge shows count of 2
|
||||
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('2')).toBeVisible();
|
||||
|
||||
// Verify both task entries are shown
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task2}`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that deselecting a task removes the filter', async ({ page }) => {
|
||||
const projectName = 'TaskDeselectProj ' + Math.floor(Math.random() * 10000);
|
||||
const task1 = 'TaskDeselect1 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTask(page, projectName, task1);
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible();
|
||||
|
||||
// Select task
|
||||
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: task1 }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();
|
||||
|
||||
// Deselect task
|
||||
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: task1 }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Tasks' }).first().getByText(/^\d+$/)
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Member Filter Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that member multiselect filters work on detailed reporting page', async ({ page }) => {
|
||||
const projectName = 'DetailMemberProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
await expect(page.getByText(`Entry for ${projectName}`)).toBeVisible();
|
||||
|
||||
// Filter by the current member
|
||||
await page.getByRole('button', { name: 'Members' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: 'John Doe' }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
// Data should still be visible since all entries belong to this member
|
||||
await expect(page.getByText(`Entry for ${projectName}`)).toBeVisible();
|
||||
|
||||
// Verify badge shows count of 1
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Members' }).first().getByText('1')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Tag Filter Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that tag filter works on detailed reporting page', async ({ page }) => {
|
||||
const tag1 = 'DetailTag1 ' + Math.floor(Math.random() * 10000);
|
||||
const tag2 = 'DetailTag2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createTimeEntryWithTag(page, tag1, '1h');
|
||||
await createTimeEntryWithTag(page, tag2, '2h');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry with tag ${tag1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry with tag ${tag2}`)).toBeVisible();
|
||||
|
||||
// Filter by tag1
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByRole('option').filter({ hasText: tag1 }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
await expect(page.getByText(`Entry with tag ${tag1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry with tag ${tag2}`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Billable Filter Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that billable filter works on detailed reporting page', async ({ page }) => {
|
||||
const projectName = 'DetailBillProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
await expect(page.getByText(`Entry for ${projectName}`)).toBeVisible();
|
||||
|
||||
// Filter by billable only
|
||||
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('option', { name: 'Billable', exact: true }).click(),
|
||||
waitForDetailedReportingUpdate(page),
|
||||
]);
|
||||
|
||||
// Switch to Non Billable
|
||||
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('option', { name: 'Non Billable', exact: true }).click(),
|
||||
waitForDetailedReportingUpdate(page),
|
||||
]);
|
||||
|
||||
// Switch back to Both
|
||||
await page.getByRole('combobox').filter({ hasText: 'Non Billable' }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('option', { name: 'Both' }).click(),
|
||||
waitForDetailedReportingUpdate(page),
|
||||
]);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Combined Filter Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that combining project and task filters narrows results', async ({ page }) => {
|
||||
const projectName = 'CombinedProj ' + Math.floor(Math.random() * 10000);
|
||||
const otherProject = 'OtherCombProj ' + Math.floor(Math.random() * 10000);
|
||||
const task1 = 'CombinedTask1 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createProject(page, otherProject);
|
||||
await createTask(page, projectName, task1);
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h');
|
||||
await createTimeEntryWithProject(page, otherProject, '2h');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${otherProject}`)).toBeVisible();
|
||||
|
||||
// Filter by project
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: projectName }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
// Additionally filter by task
|
||||
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: task1 }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
// Verify both badges show count of 1
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();
|
||||
|
||||
// Verify only the combined entry is shown
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${otherProject}`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that combining client and member filters narrows results on detailed page', async ({
|
||||
page,
|
||||
}) => {
|
||||
const client1 = 'CombClient ' + Math.floor(Math.random() * 10000);
|
||||
const project1 = 'CombClientProj ' + Math.floor(Math.random() * 10000);
|
||||
const project2 = 'CombNoClientProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createClient(page, client1);
|
||||
await createProjectWithClient(page, project1, client1);
|
||||
await createProject(page, project2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryWithProject(page, project2, '2h');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project2}`)).toBeVisible();
|
||||
|
||||
// Filter by client
|
||||
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: client1 }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
// Additionally filter by member
|
||||
await page.getByRole('button', { name: 'Members' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: 'John Doe' }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
// Only project1 entry should be visible (filtered by client + member)
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project2}`)).not.toBeVisible();
|
||||
|
||||
// Both badges should show count of 1
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Clients' }).first().getByText('1')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Members' }).first().getByText('1')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that combining tag and project filters narrows results', async ({ page }) => {
|
||||
const tag1 = 'CombTag ' + Math.floor(Math.random() * 10000);
|
||||
const project1 = 'CombTagProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, project1);
|
||||
|
||||
// Create a time entry with a project (no tag)
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
|
||||
// Create a time entry with a tag (no specific project)
|
||||
await createTimeEntryWithTag(page, tag1, '2h');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry with tag ${tag1}`)).toBeVisible();
|
||||
|
||||
// Filter by project
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: project1 }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
// Only the project entry should be visible (tagged entry has no project)
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry with tag ${tag1}`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// "No X" Filter Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that "No Project" filter shows entries without a project', async ({ page }) => {
|
||||
const project1 = 'NoProj1 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createBareTimeEntry(page, 'Bare entry no project', '30min');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
await expect(page.getByText('Bare entry no project')).toBeVisible();
|
||||
|
||||
// Open project dropdown and select "No Project"
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: 'No Project' }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
// Verify badge shows 1
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
|
||||
).toBeVisible();
|
||||
|
||||
// Only the bare entry (no project) should be visible
|
||||
await expect(page.getByText('Bare entry no project')).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${project1}`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that "No Task" filter shows entries without a task', async ({ page }) => {
|
||||
const projectName = 'NoTaskProj ' + Math.floor(Math.random() * 10000);
|
||||
const task1 = 'NoTaskFilter1 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTask(page, projectName, task1);
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h');
|
||||
await createTimeEntryWithProject(page, projectName, '30min');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();
|
||||
|
||||
// Open task dropdown and select "No Task"
|
||||
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: 'No Task' }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();
|
||||
|
||||
// Only the entry without a task should be visible
|
||||
await expect(page.getByText(`Entry for ${projectName} - ${task1}`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that "No Tag" filter shows entries without tags', async ({ page }) => {
|
||||
const tag1 = 'NoTagFilter1 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createTimeEntryWithTag(page, tag1, '1h');
|
||||
await createBareTimeEntry(page, 'Entry without any tag', '30min');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry with tag ${tag1}`)).toBeVisible();
|
||||
await expect(page.getByText('Entry without any tag')).toBeVisible();
|
||||
|
||||
// Open tag dropdown and select "No Tag"
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByRole('option').filter({ hasText: 'No Tag' }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible();
|
||||
|
||||
await expect(page.getByText('Entry without any tag')).toBeVisible();
|
||||
await expect(page.getByText(`Entry with tag ${tag1}`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that "No Client" filter shows entries without a client', async ({ page }) => {
|
||||
const client1 = 'NoClientFilter ' + Math.floor(Math.random() * 10000);
|
||||
const projectWithClient = 'NoClientProj1 ' + Math.floor(Math.random() * 10000);
|
||||
const projectNoClient = 'NoClientProj2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createClient(page, client1);
|
||||
await createProjectWithClient(page, projectWithClient, client1);
|
||||
await createProject(page, projectNoClient);
|
||||
await createTimeEntryWithProject(page, projectWithClient, '1h');
|
||||
await createTimeEntryWithProject(page, projectNoClient, '30min');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry for ${projectWithClient}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectNoClient}`)).toBeVisible();
|
||||
|
||||
// Open client dropdown and select "No Client"
|
||||
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: 'No Client' }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Clients' }).first().getByText('1')
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByText(`Entry for ${projectNoClient}`)).toBeVisible();
|
||||
await expect(page.getByText(`Entry for ${projectWithClient}`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that combining "No Project" with a project ID shows both', async ({ page }) => {
|
||||
const project1 = 'CombNoProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createBareTimeEntry(page, 'Bare combined entry', '30min');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
await expect(page.getByText('Bare combined entry')).toBeVisible();
|
||||
|
||||
// Select both "No Project" and the specific project
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: 'No Project' }).click();
|
||||
await page.getByRole('option').filter({ hasText: project1 }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
// Badge should show 2
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Projects' }).first().getByText('2')
|
||||
).toBeVisible();
|
||||
|
||||
// Both entries should be visible
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
await expect(page.getByText('Bare combined entry')).toBeVisible();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Keyboard Navigation Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that keyboard navigation works in multiselect dropdown', async ({ page }) => {
|
||||
const project1 = 'KbNavProj1 ' + Math.floor(Math.random() * 10000);
|
||||
const project2 = 'KbNavProj2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createProject(page, project2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryWithProject(page, project2, '2h');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
await expect(page.getByText(`Entry for ${project1}`)).toBeVisible();
|
||||
|
||||
// Open project dropdown
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
|
||||
// The search input should be focused, first item ("No Project") highlighted
|
||||
await expect(page.getByPlaceholder('Search for a Project...')).toBeFocused();
|
||||
|
||||
// Press ArrowDown to move to first project, then Enter to select it
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Close dropdown and verify filter applied
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||
|
||||
// Badge should show 1
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -1,149 +1,431 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import {
|
||||
goToReporting,
|
||||
createProject,
|
||||
createClient,
|
||||
createProjectWithClient,
|
||||
createTask,
|
||||
createTimeEntryWithProject,
|
||||
createTimeEntryWithProjectAndTask,
|
||||
createTimeEntryWithTag,
|
||||
createTimeEntryWithBillableStatus,
|
||||
waitForReportingUpdate,
|
||||
} from './utils/reporting';
|
||||
|
||||
async function goToTimeOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
}
|
||||
// Each test registers a new user and creates test data, which needs more time
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
|
||||
async function goToReporting(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
|
||||
}
|
||||
// ──────────────────────────────────────────────────
|
||||
// No-op Dropdown Close Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
async function goToReportingDetailed(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed');
|
||||
}
|
||||
test('test that opening and closing a filter dropdown without changes does not trigger an API request', async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectName = 'NoOpDropdown ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) {
|
||||
// First create the project through the Projects page
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(projectName);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click();
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
// Wait for the project to be created and visible in the list
|
||||
await page.getByText(projectName).waitFor({ state: 'visible' });
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Then create the time entry
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Open the dropdown menu and click "Manual time entry"
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Fill in the time entry details
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill(`Time entry for ${projectName}`);
|
||||
|
||||
await page.getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByText(projectName).click();
|
||||
|
||||
// Set duration
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
|
||||
// Submit the time entry
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Open the dropdown menu and click "Manual time entry"
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
// Fill in the time entry details
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill(`Time entry with tag ${tagName}`);
|
||||
|
||||
// Add tag
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText('Create new tag').click();
|
||||
await page.getByPlaceholder('Tag Name').fill(tagName);
|
||||
await page.getByRole('button', { name: 'Create Tag' }).click();
|
||||
// Wait for initial reporting data to fully load and all network activity to settle
|
||||
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Set duration
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
// Set up a request counter for aggregate API calls
|
||||
let aggregateRequestCount = 0;
|
||||
page.on('response', (response) => {
|
||||
if (
|
||||
response.url().includes('/time-entries/aggregate') &&
|
||||
!response.url().includes('/export') &&
|
||||
response.status() === 200
|
||||
) {
|
||||
aggregateRequestCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Submit the time entry
|
||||
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||
}
|
||||
// Open project dropdown, change nothing, close it
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
await expect(page.getByPlaceholder('Search for a Project...')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
async function createTimeEntryWithBillableStatus(
|
||||
page: Page,
|
||||
isBillable: boolean,
|
||||
duration: string
|
||||
) {
|
||||
await goToTimeOverview(page);
|
||||
// Open member dropdown, change nothing, close it
|
||||
await page.getByRole('button', { name: 'Members' }).first().click();
|
||||
await expect(page.getByPlaceholder('Search for a Member...')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Open the dropdown menu and click "Manual time entry"
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
// Open client dropdown, change nothing, close it
|
||||
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||
await expect(page.getByPlaceholder('Search for a Client...')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Fill in the time entry details
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
|
||||
// Wait for all network activity to settle before asserting no requests were made
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Set billable status
|
||||
await page.getByRole('button', { name: 'Non-Billable' }).click();
|
||||
if (!isBillable) {
|
||||
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
|
||||
} else {
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
}
|
||||
// No aggregate API requests should have been made
|
||||
expect(aggregateRequestCount).toBe(0);
|
||||
|
||||
// Set duration
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
// Verify the report data is still intact (no flash/reload)
|
||||
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
|
||||
});
|
||||
|
||||
// Submit the time entry
|
||||
await page.getByRole('button', { name: 'Create Time Entry' }).click();
|
||||
}
|
||||
// ──────────────────────────────────────────────────
|
||||
// Project Multiselect Dropdown Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that project filtering works in reporting', async ({ page }) => {
|
||||
const project1 = 'Test Project 1 ' + Math.floor(Math.random() * 10000);
|
||||
const project2 = 'Test Project 2 ' + Math.floor(Math.random() * 10000);
|
||||
test('test that project multiselect dropdown shows projects and filters reporting', async ({
|
||||
page,
|
||||
}) => {
|
||||
const project1 = 'ProjFilter1 ' + Math.floor(Math.random() * 10000);
|
||||
const project2 = 'ProjFilter2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Create time entries for both projects
|
||||
await createProject(page, project1);
|
||||
await createProject(page, project2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryWithProject(page, project2, '2h');
|
||||
|
||||
// Go to reporting and filter by project1
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Open project multiselect dropdown
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
|
||||
// Verify both projects appear as options
|
||||
await expect(page.getByRole('option').filter({ hasText: project1 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: project2 })).toBeVisible();
|
||||
|
||||
// Select project1
|
||||
await page.getByRole('option').filter({ hasText: project1 }).click();
|
||||
|
||||
await Promise.all([
|
||||
// escape
|
||||
page.keyboard.press('Escape'),
|
||||
// wait for API request to finish
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
),
|
||||
]);
|
||||
await page.waitForLoadState('networkidle');
|
||||
// Close dropdown and wait for report update
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
// Verify only project1 time entries are shown
|
||||
// Verify filter badge shows count of 1
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
|
||||
).toBeVisible();
|
||||
|
||||
// Verify only project1 data is shown
|
||||
await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that project multiselect search filters the option list', async ({ page }) => {
|
||||
const project1 = 'SearchableAlpha ' + Math.floor(Math.random() * 10000);
|
||||
const project2 = 'SearchableBeta ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createProject(page, project2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Open project multiselect dropdown
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
|
||||
// Type in search
|
||||
await page.getByPlaceholder('Search for a Project...').fill('Alpha');
|
||||
|
||||
// Verify only matching project is visible
|
||||
await expect(page.getByRole('option').filter({ hasText: project1 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: project2 })).not.toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('test that selecting multiple projects shows correct badge count', async ({ page }) => {
|
||||
const project1 = 'MultiProj1 ' + Math.floor(Math.random() * 10000);
|
||||
const project2 = 'MultiProj2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createProject(page, project2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryWithProject(page, project2, '2h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Open project dropdown and select both
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: project1 }).click();
|
||||
await page.getByRole('option').filter({ hasText: project2 }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
// Verify filter badge shows count of 2
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Projects' }).first().getByText('2')
|
||||
).toBeVisible();
|
||||
|
||||
// Verify both projects are shown in the report
|
||||
await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project2)).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that deselecting a project removes the filter', async ({ page }) => {
|
||||
const project1 = 'DeselectProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, project1);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Select project
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: project1 }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
// Verify badge count is 1
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
|
||||
).toBeVisible();
|
||||
|
||||
// Deselect project
|
||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: project1 }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
// Verify badge count is gone (no count displayed when 0)
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Projects' }).first().getByText(/^\d+$/)
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Client Multiselect Dropdown Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that client multiselect dropdown filters reporting by client', async ({ page }) => {
|
||||
const client1 = 'ClientFilter1 ' + Math.floor(Math.random() * 10000);
|
||||
const client2 = 'ClientFilter2 ' + Math.floor(Math.random() * 10000);
|
||||
const project1 = 'ClientProj1 ' + Math.floor(Math.random() * 10000);
|
||||
const project2 = 'ClientProj2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createClient(page, client1);
|
||||
await createClient(page, client2);
|
||||
await createProjectWithClient(page, project1, client1);
|
||||
await createProjectWithClient(page, project2, client2);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
await createTimeEntryWithProject(page, project2, '2h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Open client multiselect dropdown
|
||||
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||
|
||||
// Verify both clients appear
|
||||
await expect(page.getByRole('option').filter({ hasText: client1 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: client2 })).toBeVisible();
|
||||
|
||||
// Select client1
|
||||
await page.getByRole('option').filter({ hasText: client1 }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
// Verify badge shows count of 1
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Clients' }).first().getByText('1')
|
||||
).toBeVisible();
|
||||
|
||||
// Verify only project1 (belonging to client1) is shown
|
||||
await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that client multiselect search filters the option list', async ({ page }) => {
|
||||
const client1 = 'ClientSearchAlpha ' + Math.floor(Math.random() * 10000);
|
||||
const client2 = 'ClientSearchBeta ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createClient(page, client1);
|
||||
await createClient(page, client2);
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||
|
||||
// Search for "Alpha"
|
||||
await page.getByPlaceholder('Search for a Client...').fill('Alpha');
|
||||
|
||||
await expect(page.getByRole('option').filter({ hasText: client1 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: client2 })).not.toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('test that deselecting a client removes the filter', async ({ page }) => {
|
||||
const client1 = 'ClientDeselect ' + Math.floor(Math.random() * 10000);
|
||||
const project1 = 'ClientDeselectProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createClient(page, client1);
|
||||
await createProjectWithClient(page, project1, client1);
|
||||
await createTimeEntryWithProject(page, project1, '1h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Select client
|
||||
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: client1 }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Clients' }).first().getByText('1')
|
||||
).toBeVisible();
|
||||
|
||||
// Deselect client
|
||||
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: client1 }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Clients' }).first().getByText(/^\d+$/)
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Task Multiselect Dropdown Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that task filtering works in reporting', async ({ page }) => {
|
||||
const projectName = 'Task Filter Proj ' + Math.floor(Math.random() * 10000);
|
||||
const task1 = 'Task Filter A ' + Math.floor(Math.random() * 10000);
|
||||
const task2 = 'Task Filter B ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '30min');
|
||||
await createTask(page, projectName, task1);
|
||||
await createTask(page, projectName, task2);
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task1, '1h');
|
||||
await createTimeEntryWithProjectAndTask(page, projectName, task2, '2h');
|
||||
|
||||
// Go to reporting and group by task to see individual tasks
|
||||
await goToReporting(page);
|
||||
|
||||
// Filter by task1
|
||||
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: task1 }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
// Verify the report only shows 1h (task1's duration)
|
||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that task multiselect search filters the option list', async ({ page }) => {
|
||||
const projectName = 'TaskSearchProj ' + Math.floor(Math.random() * 10000);
|
||||
const task1 = 'TaskSearchAlpha ' + Math.floor(Math.random() * 10000);
|
||||
const task2 = 'TaskSearchBeta ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTask(page, projectName, task1);
|
||||
await createTask(page, projectName, task2);
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||
|
||||
await page.getByPlaceholder('Search for a Task...').fill('Alpha');
|
||||
|
||||
await expect(page.getByRole('option').filter({ hasText: task1 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: task2 })).not.toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Member Multiselect Dropdown Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that member multiselect dropdown shows current member and filters reporting', async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectName = 'MemberFilterProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Open member multiselect dropdown
|
||||
await page.getByRole('button', { name: 'Members' }).first().click();
|
||||
|
||||
// Verify the current user (John Doe from fixture) appears as an option
|
||||
await expect(page.getByRole('option').filter({ hasText: 'John Doe' })).toBeVisible();
|
||||
|
||||
// Select the member
|
||||
await page.getByRole('option').filter({ hasText: 'John Doe' }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
// Verify badge shows count of 1
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Members' }).first().getByText('1')
|
||||
).toBeVisible();
|
||||
|
||||
// Verify data is still shown (since all entries belong to this member)
|
||||
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that member multiselect search filters the option list', async ({ page }) => {
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Members' }).first().click();
|
||||
|
||||
// Search for the registered user
|
||||
await page.getByPlaceholder('Search for a Member...').fill('John');
|
||||
await expect(page.getByRole('option').filter({ hasText: 'John Doe' })).toBeVisible();
|
||||
|
||||
// Search for a non-existent member
|
||||
await page.getByPlaceholder('Search for a Member...').fill('NonExistentMember');
|
||||
await expect(page.getByRole('option')).not.toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('test that deselecting a member removes the filter', async ({ page }) => {
|
||||
const projectName = 'MemberDeselectProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Select member
|
||||
await page.getByRole('button', { name: 'Members' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: 'John Doe' }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Members' }).first().getByText('1')
|
||||
).toBeVisible();
|
||||
|
||||
// Deselect member
|
||||
await page.getByRole('button', { name: 'Members' }).first().click();
|
||||
await page.getByRole('option').filter({ hasText: 'John Doe' }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
// Verify badge count is gone
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Members' }).first().getByText(/^\d+$/)
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Tag Dropdown Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that tag filtering works in reporting', async ({ page }) => {
|
||||
const tag1 = 'Test Tag 1 ' + Math.floor(Math.random() * 10000);
|
||||
const tag2 = 'Test Tag 2 ' + Math.floor(Math.random() * 10000);
|
||||
@@ -154,26 +436,111 @@ test('test that tag filtering works in reporting', async ({ page }) => {
|
||||
|
||||
// Go to reporting and filter by tag1
|
||||
await goToReporting(page);
|
||||
// wait for all requests to finish
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByRole('button', { name: 'Tags' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText(tag1).click();
|
||||
|
||||
await Promise.all([
|
||||
// escape
|
||||
page.keyboard.press('Escape'),
|
||||
// wait for API request to finish
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
),
|
||||
]);
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
// Verify only time entries with tag1 are shown
|
||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that tag dropdown search filters the option list', async ({ page }) => {
|
||||
const tag1 = 'TagSearchAlpha ' + Math.floor(Math.random() * 10000);
|
||||
const tag2 = 'TagSearchBeta ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createTimeEntryWithTag(page, tag1, '1h');
|
||||
await createTimeEntryWithTag(page, tag2, '2h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
|
||||
await page.getByPlaceholder('Search for a Tag...').fill('Alpha');
|
||||
|
||||
await expect(page.getByRole('option').filter({ hasText: tag1 })).toBeVisible();
|
||||
await expect(page.getByRole('option').filter({ hasText: tag2 })).not.toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('test that selecting multiple tags shows correct badge count', async ({ page }) => {
|
||||
const tag1 = 'MultiTag1 ' + Math.floor(Math.random() * 10000);
|
||||
const tag2 = 'MultiTag2 ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createTimeEntryWithTag(page, tag1, '1h');
|
||||
await createTimeEntryWithTag(page, tag2, '2h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Select both tags
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByRole('option').filter({ hasText: tag1 }).click();
|
||||
await page.getByRole('option').filter({ hasText: tag2 }).click();
|
||||
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
// Verify badge shows count of 2
|
||||
await expect(page.getByRole('button', { name: 'Tags' }).getByText('2')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that deselecting a tag removes the filter', async ({ page }) => {
|
||||
const tag1 = 'TagDeselect ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createTimeEntryWithTag(page, tag1, '1h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Select tag
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByRole('option').filter({ hasText: tag1 }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible();
|
||||
|
||||
// Deselect tag
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByRole('option').filter({ hasText: tag1 }).click();
|
||||
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Tags' }).getByText(/^\d+$/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that creating a tag inline from the reporting filter works', async ({ page }) => {
|
||||
const projectName = 'TagCreateProj ' + Math.floor(Math.random() * 10000);
|
||||
const newTag = 'InlineTag ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Open tag dropdown and create a new tag
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText('Create new tag').click();
|
||||
await page.getByPlaceholder('Tag Name').fill(newTag);
|
||||
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Tag' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/tags') && response.status() === 201
|
||||
),
|
||||
]);
|
||||
|
||||
// The new tag should now be selected in the dropdown (badge should show 1)
|
||||
await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Billable Select Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that billable status filtering works in reporting', async ({ page }) => {
|
||||
// Create billable and non-billable time entries
|
||||
await createTimeEntryWithBillableStatus(page, true, '1h');
|
||||
@@ -182,60 +549,205 @@ test('test that billable status filtering works in reporting', async ({ page })
|
||||
// Go to reporting and filter by billable
|
||||
await goToReporting(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Billable' }).click();
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
|
||||
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
|
||||
await Promise.all([
|
||||
// escape
|
||||
page.keyboard.press('Escape'),
|
||||
// wait for API request to finish
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
),
|
||||
page.getByRole('option', { name: 'Billable', exact: true }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that detailed view shows time entries correctly', async ({ page }) => {
|
||||
const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000);
|
||||
test('test that billable filter can switch between all three states', async ({ page }) => {
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Create a time entry
|
||||
const billableSelect = page.getByRole('combobox').filter({ hasText: 'Billable' });
|
||||
|
||||
// Switch to Billable
|
||||
await billableSelect.click();
|
||||
await Promise.all([
|
||||
page.getByRole('option', { name: 'Billable', exact: true }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
|
||||
// Switch to Non Billable
|
||||
await billableSelect.click();
|
||||
await Promise.all([
|
||||
page.getByRole('option', { name: 'Non Billable', exact: true }).click(),
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
|
||||
// Verify "Non Billable" is displayed
|
||||
await expect(billableSelect).toContainText('Non Billable');
|
||||
|
||||
// Switch back to Both (cached by TanStack Query, no new API request)
|
||||
await billableSelect.click();
|
||||
await page.getByRole('option', { name: 'Both' }).click();
|
||||
await expect(billableSelect).toContainText('Billable');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Rounding Controls Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that rounding can be enabled', async ({ page }) => {
|
||||
const projectName = 'RoundingProj ' + Math.floor(Math.random() * 10000);
|
||||
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h 7min');
|
||||
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Verify rounding is off by default
|
||||
await expect(page.getByRole('button', { name: /Rounding off/ })).toBeVisible();
|
||||
|
||||
// Open rounding controls and enable rounding
|
||||
await page.getByRole('button', { name: /Rounding off/ }).click();
|
||||
|
||||
const reportUpdatePromise = waitForReportingUpdate(page);
|
||||
await page.getByRole('switch', { name: 'Enable Rounding' }).click();
|
||||
await reportUpdatePromise;
|
||||
|
||||
// Close the popover by clicking elsewhere
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify button text changed to "on"
|
||||
await expect(page.getByRole('button', { name: /Rounding on/ })).toBeVisible();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Export Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that export dropdown shows all format options', async ({ page }) => {
|
||||
const projectName = 'Export Test ' + Math.floor(Math.random() * 10000);
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
// Go to detailed reporting view
|
||||
await goToReportingDetailed(page);
|
||||
// Go to reporting page
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Verify the time entry is shown with all details
|
||||
await expect(page.getByText(projectName, { exact: true })).toBeVisible();
|
||||
await expect(page.locator('input[name="Duration"]')).toHaveValue('1h 00min');
|
||||
await expect(page.getByText('Time entry for ' + projectName, { exact: true })).toBeVisible();
|
||||
// Click the export button
|
||||
await page.getByRole('button', { name: 'Export' }).click();
|
||||
|
||||
// Verify all 4 format options are visible
|
||||
await expect(page.getByRole('menuitem', { name: /Export as PDF/i })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: /Export as Excel/i })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: /Export as CSV/i })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: /Export as ODS/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that updating duration in detailed view works correctly', async ({ page }) => {
|
||||
const projectName = 'Duration Update Project ' + Math.floor(Math.random() * 10000);
|
||||
const initialDuration = '1h';
|
||||
const updatedDuration = '2h 30min';
|
||||
test('test that CSV export triggers download', async ({ page }) => {
|
||||
const projectName = 'CSV Export ' + Math.floor(Math.random() * 10000);
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
// Create a time entry with initial duration
|
||||
await createTimeEntryWithProject(page, projectName, initialDuration);
|
||||
// Go to reporting page
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Go to detailed reporting view
|
||||
await goToReportingDetailed(page);
|
||||
// Click export and select CSV, wait for the export API response with a download URL
|
||||
await page.getByRole('button', { name: 'Export' }).click();
|
||||
const [exportResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate/export') &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('menuitem', { name: /Export as CSV/i }).click(),
|
||||
]);
|
||||
|
||||
// Find and update the duration
|
||||
const durationInput = page.locator('input[name="Duration"]').first();
|
||||
await durationInput.click();
|
||||
await durationInput.fill(updatedDuration);
|
||||
await durationInput.press('Enter');
|
||||
// Verify the API returned a download URL
|
||||
const responseBody = await exportResponse.json();
|
||||
expect(responseBody.download_url).toBeTruthy();
|
||||
|
||||
// Wait for the update to be processed
|
||||
await page.waitForLoadState('networkidle');
|
||||
// Verify the export success modal appeared
|
||||
await expect(page.getByText('Export Successful!')).toBeVisible();
|
||||
|
||||
// Verify the new duration is displayed
|
||||
await expect(durationInput).toHaveValue(updatedDuration);
|
||||
// Verify the download URL is accessible and returns CSV content
|
||||
const downloadResponse = await page.request.get(responseBody.download_url);
|
||||
expect(downloadResponse.ok()).toBeTruthy();
|
||||
const contentType = downloadResponse.headers()['content-type'];
|
||||
expect(contentType).toContain('csv');
|
||||
});
|
||||
|
||||
// TODO: test that date range filtering works in reporting
|
||||
// ──────────────────────────────────────────────────
|
||||
// Group By Tests
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
test('test that group by select changes report grouping', async ({ page }) => {
|
||||
const projectName = 'GroupBy Test ' + Math.floor(Math.random() * 10000);
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
// Go to reporting page
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Find the "Group by" selects within the reporting table
|
||||
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
|
||||
|
||||
// Click the first group by select to change grouping
|
||||
await groupBySelects.filter({ hasText: 'Project' }).first().click();
|
||||
|
||||
// Select "Members" option and wait for the table query to update (has sub_group param)
|
||||
const [aggregateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') &&
|
||||
response.url().includes('sub_group') &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('option', { name: 'Members' }).click(),
|
||||
]);
|
||||
|
||||
// Verify the API request contains the correct group parameter
|
||||
const requestUrl = new URL(aggregateResponse.url());
|
||||
expect(requestUrl.searchParams.get('group')).toBe('user');
|
||||
|
||||
// Verify the grouping changed (the select should now show "Members")
|
||||
await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that setting group by to current sub group triggers sub group fallback', async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectName = 'Fallback Test ' + Math.floor(Math.random() * 10000);
|
||||
await createProject(page, projectName);
|
||||
await createTimeEntryWithProject(page, projectName, '1h');
|
||||
|
||||
// Go to reporting page
|
||||
await goToReporting(page);
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
|
||||
// Find the "Group by" selects within the reporting table
|
||||
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
|
||||
|
||||
// Default state: group=Project, subGroup=Tasks
|
||||
// Change group to "Tasks" (which is the current sub group)
|
||||
await groupBySelects.filter({ hasText: 'Projects' }).first().click();
|
||||
|
||||
const [aggregateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') &&
|
||||
response.url().includes('sub_group') &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('option', { name: 'Tasks' }).click(),
|
||||
]);
|
||||
|
||||
// Verify the API request has group=task and sub_group changed away from task
|
||||
const requestUrl = new URL(aggregateResponse.url());
|
||||
expect(requestUrl.searchParams.get('group')).toBe('task');
|
||||
expect(requestUrl.searchParams.get('sub_group')).not.toBe('task');
|
||||
|
||||
// The group should now be "Tasks"
|
||||
await expect(groupBySelects.filter({ hasText: 'Tasks' }).first()).toBeVisible();
|
||||
|
||||
// The sub group should have fallen back to a different value (not "Tasks")
|
||||
await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
141
e2e/time.spec.ts
141
e2e/time.spec.ts
@@ -53,7 +53,7 @@ test('test that starting and stopping an empty time entry shows a new time entry
|
||||
// Test that description update works
|
||||
|
||||
async function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) {
|
||||
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(/bg-tertiary/);
|
||||
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(/bg-quaternary/);
|
||||
}
|
||||
|
||||
test('test that updating a description of a time entry in the overview works on blur', async ({
|
||||
@@ -224,7 +224,7 @@ test('test that starting a time entry from the overview works', async ({ page })
|
||||
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
const startButton = newTimeEntry.getByTestId('timer_button');
|
||||
await expect(startButton).toHaveClass(/bg-tertiary/);
|
||||
await expect(startButton).toHaveClass(/bg-quaternary/);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
@@ -307,12 +307,143 @@ test.skip('test that load more works when the end of page is reached', async ({
|
||||
|
||||
// TODO: Test that time entries are loaded at the end of the page
|
||||
|
||||
// TODO: Test manual time entries
|
||||
|
||||
// TODO: Test Grouped time entries by description/project
|
||||
|
||||
// TODO: Add Test for Date Update
|
||||
|
||||
// TODO: Test that project can be created in the time entry row
|
||||
|
||||
// TODO: Add Tests for Mass Update
|
||||
test('test that editing billable status via the edit modal works', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
await createEmptyTimeEntry(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
await assertThatTimeEntryRowIsStopped(newTimeEntry);
|
||||
|
||||
// Open edit modal via the actions dropdown
|
||||
const actionsDropdown = newTimeEntry
|
||||
.getByRole('button', { name: 'Actions for the time entry' })
|
||||
.first();
|
||||
await actionsDropdown.click();
|
||||
await page.getByTestId('time_entry_edit').click();
|
||||
|
||||
// Verify the edit dialog is visible
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Change billable status to Billable
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('combobox')
|
||||
.filter({ hasText: 'Non-Billable' })
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
|
||||
// Save the time entry and verify the response has billable=true
|
||||
const [updateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 200
|
||||
),
|
||||
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||
]);
|
||||
const updateBody = await updateResponse.json();
|
||||
expect(updateBody.data.billable).toBe(true);
|
||||
|
||||
// Verify the dialog closed
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
|
||||
// Re-open the edit modal and verify it now shows "Billable"
|
||||
await actionsDropdown.click();
|
||||
await page.getByTestId('time_entry_edit').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that mass update billable status works', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
await createEmptyTimeEntry(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
await assertThatTimeEntryRowIsStopped(timeEntryRows.first());
|
||||
|
||||
// Select the time entry via the "Select All" checkbox
|
||||
await page.getByLabel('Select All').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
// Open mass update modal via the Edit button in the mass action row
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Change billable status to Billable
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('combobox')
|
||||
.filter({ hasText: 'Set billable status' })
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
|
||||
// Submit the mass update
|
||||
const [massUpdateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 200
|
||||
),
|
||||
page.getByRole('button', { name: 'Update Time Entries' }).click(),
|
||||
]);
|
||||
const massUpdateBody = await massUpdateResponse.json();
|
||||
expect(massUpdateBody.data.billable).toBe(true);
|
||||
|
||||
// Verify dialog closes
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
|
||||
// Verify the UI reflects the billable status by re-opening the edit modal
|
||||
const actionsDropdown = page
|
||||
.locator('[data-testid="time_entry_row"]')
|
||||
.first()
|
||||
.getByRole('button', { name: 'Actions' });
|
||||
await actionsDropdown.click();
|
||||
await page.getByTestId('time_entry_edit').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that setting billable status via the create modal works', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Open the dropdown menu and click "Manual time entry"
|
||||
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('Billable create test');
|
||||
|
||||
// Change billable status to Billable
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('combobox')
|
||||
.filter({ hasText: 'Non-Billable' })
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
|
||||
// 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 the time entry was created with billable=true
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
page.waitForResponse(
|
||||
async (response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.status() === 201 &&
|
||||
(await response.json()).data.billable === true
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
275
e2e/utils/reporting.ts
Normal file
275
e2e/utils/reporting.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../../playwright/config';
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Navigation
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function goToReporting(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
|
||||
}
|
||||
|
||||
export async function goToReportingDetailed(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Entity creation
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function createProject(page: Page, projectName: string) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
await expect(page.getByRole('button', { name: 'Create Project' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project name').fill(projectName);
|
||||
await Promise.all([
|
||||
page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/projects') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201
|
||||
),
|
||||
]);
|
||||
await expect(page.getByText(projectName)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function createClient(page: Page, clientName: string) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||
await expect(page.getByRole('button', { name: 'Create Client' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Create Client' }).click();
|
||||
await page.getByPlaceholder('Client Name').fill(clientName);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Client' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/clients') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201
|
||||
),
|
||||
]);
|
||||
await expect(page.getByText(clientName)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function createProjectWithClient(
|
||||
page: Page,
|
||||
projectName: string,
|
||||
clientName: string
|
||||
) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
await expect(page.getByRole('button', { name: 'Create Project' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project name').fill(projectName);
|
||||
|
||||
// Select client in the project create modal
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'No Client' }).click();
|
||||
await page.getByRole('option', { name: clientName }).click();
|
||||
|
||||
await Promise.all([
|
||||
page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/projects') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201
|
||||
),
|
||||
]);
|
||||
await expect(page.getByText(projectName)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function createTask(page: Page, projectName: string, taskName: string) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
await expect(page.getByText(projectName)).toBeVisible();
|
||||
await page.getByText(projectName).click();
|
||||
await page.getByRole('button', { name: 'Create Task' }).click();
|
||||
await page.getByPlaceholder('Task Name').fill(taskName);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Task' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/tasks') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201
|
||||
),
|
||||
]);
|
||||
await expect(page.getByText(taskName)).toBeVisible();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Time entry creation
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function createTimeEntryWithProject(
|
||||
page: Page,
|
||||
projectName: string,
|
||||
duration: string
|
||||
) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill(`Entry for ${projectName}`);
|
||||
|
||||
await page.getByRole('button', { name: 'No Project' }).click();
|
||||
await page.getByRole('option').filter({ hasText: projectName }).click();
|
||||
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function createTimeEntryWithProjectAndTask(
|
||||
page: Page,
|
||||
projectName: string,
|
||||
taskName: string,
|
||||
duration: string
|
||||
) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill(`Entry for ${projectName} - ${taskName}`);
|
||||
|
||||
// Open the project/task dropdown
|
||||
await page.getByRole('button', { name: 'No Project' }).click();
|
||||
|
||||
// Expand the project's tasks by clicking the "Tasks" button
|
||||
const projectOption = page.getByRole('option').filter({ hasText: projectName });
|
||||
await projectOption.getByText(/Tasks/).click();
|
||||
|
||||
// Select the task (this also selects the project and closes the dropdown)
|
||||
await page.getByText(taskName, { exact: true }).click();
|
||||
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill(`Entry with tag ${tagName}`);
|
||||
|
||||
// Add tag
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await page.getByText('Create new tag').click();
|
||||
await page.getByPlaceholder('Tag Name').fill(tagName);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Tag' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/tags') && response.status() === 201
|
||||
),
|
||||
]);
|
||||
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function createBareTimeEntry(page: Page, description: string, duration: string) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(description);
|
||||
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function createTimeEntryWithBillableStatus(
|
||||
page: Page,
|
||||
isBillable: boolean,
|
||||
duration: string
|
||||
) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
|
||||
|
||||
if (isBillable) {
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('combobox')
|
||||
.filter({ hasText: 'Non-Billable' })
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
}
|
||||
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Wait helpers
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function waitForReportingUpdate(page: Page) {
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries/aggregate') && response.status() === 200
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForDetailedReportingUpdate(page: Page) {
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
!response.url().includes('/aggregate') &&
|
||||
response.request().method() === 'GET' &&
|
||||
response.status() === 200
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user