mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
781 lines
32 KiB
TypeScript
781 lines
32 KiB
TypeScript
import { expect, test } from '../playwright/fixtures';
|
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
|
import {
|
|
assertThatTimerHasStarted,
|
|
assertThatTimerIsStopped,
|
|
newTimeEntryResponse,
|
|
startOrStopTimerWithButton,
|
|
stoppedTimeEntryResponse,
|
|
} from './utils/currentTimeEntry';
|
|
import type { Page } from '@playwright/test';
|
|
import { newTagResponse } from './utils/tags';
|
|
import {
|
|
createProjectViaApi,
|
|
createTaskViaApi,
|
|
createClientViaApi,
|
|
createTimeEntryViaApi,
|
|
archiveProjectViaApi,
|
|
markTaskDoneViaApi,
|
|
updateOrganizationCurrencyViaWeb,
|
|
} from './utils/api';
|
|
|
|
// Date picker button name patterns for different date formats
|
|
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
|
|
|
|
async function goToDashboard(page: Page) {
|
|
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
|
}
|
|
|
|
test('test that starting and stopping a timer without description and project works', async ({
|
|
page,
|
|
}) => {
|
|
await goToDashboard(page);
|
|
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
|
await assertThatTimerHasStarted(page);
|
|
await page.waitForTimeout(1500);
|
|
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that billable icon shows dollar sign for USD currency', async ({ page, ctx }) => {
|
|
await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');
|
|
await goToDashboard(page);
|
|
await page.waitForLoadState('networkidle');
|
|
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
|
|
await expect(billableButton).toBeVisible();
|
|
await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 8 14');
|
|
});
|
|
|
|
test('test that billable icon shows euro sign for EUR currency', async ({ page, ctx }) => {
|
|
await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');
|
|
await goToDashboard(page);
|
|
await page.waitForLoadState('networkidle');
|
|
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
|
|
await expect(billableButton).toBeVisible();
|
|
await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 12 12');
|
|
});
|
|
|
|
test('test that starting and stopping a timer with a description works', async ({ page }) => {
|
|
await goToDashboard(page);
|
|
// Wait for the description input to be editable before filling
|
|
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
|
await page.getByTestId('time_entry_description').fill('New Time Entry Description');
|
|
await Promise.all([
|
|
newTimeEntryResponse(page, {
|
|
description: 'New Time Entry Description',
|
|
}),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerHasStarted(page);
|
|
await page.waitForTimeout(1500);
|
|
await Promise.all([
|
|
stoppedTimeEntryResponse(page, {
|
|
description: 'New Time Entry Description',
|
|
}),
|
|
await startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that starting the time entry starts the live timer and that it keeps running after reload', async ({
|
|
page,
|
|
}) => {
|
|
await goToDashboard(page);
|
|
|
|
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
|
await assertThatTimerHasStarted(page);
|
|
const beforeTimerValue = await page.getByTestId('time_entry_time').inputValue();
|
|
await page.waitForTimeout(2000);
|
|
const afterWaitTimeValue = await page.getByTestId('time_entry_time').inputValue();
|
|
expect(afterWaitTimeValue).not.toEqual(beforeTimerValue);
|
|
await page.reload();
|
|
await expect(page.getByTestId('time_entry_time')).toBeVisible();
|
|
|
|
const afterReloadTimerValue = await page.getByTestId('time_entry_time').inputValue();
|
|
await page.waitForTimeout(2000);
|
|
const afterReloadAfterWaitTimerValue = await page.getByTestId('time_entry_time').inputValue();
|
|
expect(afterReloadTimerValue).not.toEqual(afterReloadAfterWaitTimerValue);
|
|
});
|
|
|
|
test('test that starting and updating the description while running works', async ({ page }) => {
|
|
await goToDashboard(page);
|
|
|
|
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
|
await assertThatTimerHasStarted(page);
|
|
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
|
await page.getByTestId('time_entry_description').fill('New Time Entry Description');
|
|
|
|
await Promise.all([
|
|
newTimeEntryResponse(page, {
|
|
status: 200,
|
|
description: 'New Time Entry Description',
|
|
}),
|
|
page.getByTestId('time_entry_description').press('Tab'),
|
|
]);
|
|
await Promise.all([
|
|
stoppedTimeEntryResponse(page, {
|
|
description: 'New Time Entry Description',
|
|
}),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that starting and updating the time while running works', async ({ page }) => {
|
|
await goToDashboard(page);
|
|
const [createResponse] = await Promise.all([
|
|
newTimeEntryResponse(page),
|
|
await startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerHasStarted(page);
|
|
await expect(page.getByTestId('time_entry_time')).toBeEditable();
|
|
await page.getByTestId('time_entry_time').fill('20min');
|
|
|
|
await Promise.all([
|
|
page.waitForResponse(async (response) => {
|
|
return (
|
|
response.url().includes('/time-entries') &&
|
|
response.status() === 200 &&
|
|
(await response.headerValue('Content-Type')) === 'application/json' &&
|
|
(await response.json()).data.id !== null &&
|
|
(await response.json()).data.start !== null &&
|
|
(await response.json()).data.start !== (await createResponse.json()).data.start &&
|
|
(await response.json()).data.end === null &&
|
|
(await response.json()).data.project_id === null &&
|
|
(await response.json()).data.description === '' &&
|
|
(await response.json()).data.task_id === null &&
|
|
(await response.json()).data.user_id !== null &&
|
|
JSON.stringify((await response.json()).data.tags) === JSON.stringify([])
|
|
);
|
|
}),
|
|
page.getByTestId('time_entry_time').press('Enter'),
|
|
]);
|
|
|
|
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
|
|
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that entering a human readable time starts the timer on blur', async ({ page }) => {
|
|
await goToDashboard(page);
|
|
await page.getByTestId('time_entry_time').fill('20min');
|
|
await Promise.all([
|
|
newTimeEntryResponse(page),
|
|
page.getByTestId('time_entry_time').press('Tab'),
|
|
]);
|
|
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20:/);
|
|
await assertThatTimerHasStarted(page);
|
|
|
|
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that entering a number in the time range starts the timer on blur', async ({ page }) => {
|
|
await goToDashboard(page);
|
|
await page.getByTestId('time_entry_time').fill('5');
|
|
await Promise.all([
|
|
newTimeEntryResponse(page),
|
|
page.getByTestId('time_entry_time').press('Tab'),
|
|
]);
|
|
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:05:/);
|
|
await assertThatTimerHasStarted(page);
|
|
|
|
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that entering a value with the format hh:mm in the time range starts the timer on blur', async ({
|
|
page,
|
|
}) => {
|
|
await goToDashboard(page);
|
|
await page.getByTestId('time_entry_time').fill('12:30');
|
|
await Promise.all([
|
|
newTimeEntryResponse(page),
|
|
page.getByTestId('time_entry_time').press('Tab'),
|
|
]);
|
|
await expect(page.getByTestId('time_entry_time')).toHaveValue(/12:30:/);
|
|
await assertThatTimerHasStarted(page);
|
|
|
|
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that entering a random value in the time range does not start the timer on blur', async ({
|
|
page,
|
|
}) => {
|
|
await goToDashboard(page);
|
|
await page.getByTestId('time_entry_time').fill('asdasdasd');
|
|
await page.getByTestId('time_entry_time').press('Tab');
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that entering a time starts the timer on enter', async ({ page }) => {
|
|
await goToDashboard(page);
|
|
await page.getByTestId('time_entry_time').fill('20min');
|
|
await Promise.all([
|
|
newTimeEntryResponse(page),
|
|
page.getByTestId('time_entry_time').press('Enter'),
|
|
]);
|
|
await assertThatTimerHasStarted(page);
|
|
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that adding a new tag works', async ({ page }) => {
|
|
const newTagName = 'New Tag' + Math.floor(Math.random() * 10000);
|
|
await goToDashboard(page);
|
|
|
|
await page.getByTestId('tag_dropdown').click();
|
|
await page.getByText('Create new tag').click();
|
|
await page.getByPlaceholder('Tag Name').fill(newTagName);
|
|
|
|
await Promise.all([
|
|
newTagResponse(page, { name: newTagName }),
|
|
page.getByRole('button', { name: 'Create Tag' }).click(),
|
|
]);
|
|
|
|
// Wait for tags query refetch after invalidation
|
|
await page.waitForResponse(
|
|
(response) => response.url().includes('/tags') && response.status() === 200
|
|
);
|
|
|
|
await page.getByTestId('tag_dropdown').click();
|
|
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
|
|
});
|
|
|
|
test('test that adding a new tag when the timer is running', async ({ page }) => {
|
|
const newTagName = 'New Tag' + Math.floor(Math.random() * 10000);
|
|
await goToDashboard(page);
|
|
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
|
await assertThatTimerHasStarted(page);
|
|
await page.getByTestId('tag_dropdown').click();
|
|
await page.getByText('Create new tag').click();
|
|
await page.getByPlaceholder('Tag Name').fill(newTagName);
|
|
|
|
const [tagCreateResponse] = await Promise.all([
|
|
newTagResponse(page, { name: newTagName }),
|
|
page.getByRole('button', { name: 'Create Tag' }).click(),
|
|
]);
|
|
const tagId = (await tagCreateResponse.json()).data.id;
|
|
await newTimeEntryResponse(page, { status: 200, tags: [tagId] });
|
|
await page.getByTestId('tag_dropdown').click();
|
|
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
|
|
await page.getByTestId('tag_dropdown_search').press('Escape');
|
|
await expect(page.getByTestId('tag_dropdown_search')).not.toBeVisible();
|
|
|
|
await Promise.all([
|
|
stoppedTimeEntryResponse(page, { tags: [tagId] }),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that setting an end time with a different date via the timetracker range selector works', async ({
|
|
page,
|
|
}) => {
|
|
await goToDashboard(page);
|
|
|
|
// Start a timer
|
|
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
|
await assertThatTimerHasStarted(page);
|
|
|
|
// Open the time range dropdown by clicking on the time display
|
|
await page.getByTestId('time_entry_time').click();
|
|
const rangeStart = page.getByTestId('time_entry_range_start');
|
|
await expect(rangeStart).toBeVisible();
|
|
|
|
// Click "Set End Time" button
|
|
await page.getByRole('button', { name: 'Set End Time' }).click();
|
|
|
|
// The end time picker should now be visible with a Confirm button
|
|
const rangeEnd = page.getByTestId('time_entry_range_end');
|
|
await expect(rangeEnd).toBeVisible();
|
|
const confirmButton = page.getByRole('button', { name: 'Confirm' });
|
|
await expect(confirmButton).toBeVisible();
|
|
|
|
// Click the end date picker to change the date
|
|
const endDatePickers = page.getByRole('button', { name: DATE_DISPLAY_PATTERN });
|
|
// The second date picker is the end date (first is the start date)
|
|
const endDatePicker = endDatePickers.nth(1);
|
|
await expect(endDatePicker).toBeVisible();
|
|
await endDatePicker.click();
|
|
|
|
// Calendar should appear
|
|
const calendarGrid = page.getByRole('gridcell').first();
|
|
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
|
|
|
|
// Navigate to the next month and select a day to ensure end > start
|
|
await page.getByRole('button', { name: /Next/i }).click();
|
|
await page.getByRole('gridcell').filter({ hasText: /^15$/ }).first().click();
|
|
|
|
// The dropdown should still be open after selecting a date (not auto-closed)
|
|
await expect(rangeEnd).toBeVisible();
|
|
await expect(confirmButton).toBeVisible();
|
|
|
|
// Click Confirm to finalize and verify the API call
|
|
const [updateResponse] = await Promise.all([
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/time-entries') &&
|
|
response.request().method() === 'PUT' &&
|
|
response.status() === 200
|
|
),
|
|
confirmButton.click(),
|
|
]);
|
|
const updateBody = await updateResponse.json();
|
|
expect(updateBody.data.start).toBeTruthy();
|
|
expect(updateBody.data.end).toBeTruthy();
|
|
});
|
|
|
|
test('test that timer starts on enter with description', async ({ page }) => {
|
|
await goToDashboard(page);
|
|
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
|
await page.getByTestId('time_entry_description').fill('Start on Enter');
|
|
|
|
await Promise.all([
|
|
newTimeEntryResponse(page, { description: 'Start on Enter' }),
|
|
page.getByTestId('time_entry_description').press('Enter'),
|
|
]);
|
|
await assertThatTimerHasStarted(page);
|
|
|
|
await Promise.all([
|
|
stoppedTimeEntryResponse(page, { description: 'Start on Enter' }),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that timer started on dashboard is visible on time page', async ({ page }) => {
|
|
await goToDashboard(page);
|
|
|
|
// Start timer on dashboard
|
|
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
|
await page.getByTestId('time_entry_description').fill('Sync test');
|
|
await Promise.all([
|
|
newTimeEntryResponse(page, { description: 'Sync test' }),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerHasStarted(page);
|
|
|
|
// Navigate to time page
|
|
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
|
|
|
// Timer should still be running (the timer button should be red/active)
|
|
await expect(
|
|
page
|
|
.getByTestId('dashboard_timer')
|
|
.getByTestId('timer_button')
|
|
.and(page.locator(':visible'))
|
|
).toHaveClass(/bg-red-400\/80/);
|
|
|
|
// Stop the timer
|
|
await Promise.all([
|
|
stoppedTimeEntryResponse(page, { description: 'Sync test' }),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that picking a recently tracked entry starts a timer with its fields', async ({
|
|
page,
|
|
ctx,
|
|
}) => {
|
|
const project = await createProjectViaApi(ctx, {
|
|
name: `RecentProj ${Math.floor(Math.random() * 100000)}`,
|
|
is_billable: false,
|
|
});
|
|
await createTimeEntryViaApi(ctx, {
|
|
description: 'Recent work item',
|
|
duration: '1h',
|
|
projectId: project.id,
|
|
});
|
|
|
|
await goToDashboard(page);
|
|
const description = page.getByTestId('time_entry_description');
|
|
await expect(description).toBeEditable();
|
|
|
|
// Focusing the description opens the "Recently Tracked" dropdown listing the finished entry.
|
|
await description.click();
|
|
const recentEntry = page.getByText('Recent work item').first();
|
|
await expect(recentEntry).toBeVisible();
|
|
|
|
// Clicking it (mousedown) copies its fields — including the project — into a new running entry.
|
|
await Promise.all([
|
|
page.waitForResponse(async (response) => {
|
|
if (
|
|
!response.url().includes('/time-entries') ||
|
|
response.request().method() !== 'POST' ||
|
|
response.status() !== 201
|
|
) {
|
|
return false;
|
|
}
|
|
const body = await response.json();
|
|
return (
|
|
body.data.description === 'Recent work item' &&
|
|
body.data.project_id === project.id &&
|
|
body.data.end === null
|
|
);
|
|
}),
|
|
recentEntry.click(),
|
|
]);
|
|
await assertThatTimerHasStarted(page);
|
|
await expect(description).toHaveValue('Recent work item');
|
|
await expect(page.getByRole('button', { name: project.name })).toBeVisible();
|
|
|
|
// Cleanup: stop the running (project-bearing) entry
|
|
await Promise.all([
|
|
page.waitForResponse(async (response) => {
|
|
if (response.status() !== 200 || !response.url().includes('/time-entries/')) {
|
|
return false;
|
|
}
|
|
const body = await response.json();
|
|
return body.data.description === 'Recent work item' && body.data.end !== null;
|
|
}),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
test('test that creating a new project from the time tracker dropdown prefills the search text', async ({
|
|
page,
|
|
ctx,
|
|
}) => {
|
|
const existingProjectName = 'Existing Project ' + Math.floor(Math.random() * 10000);
|
|
const searchText = 'PrefillProject ' + Math.floor(Math.random() * 10000);
|
|
|
|
// Create a project so the dropdown renders (not the "Add new project" button)
|
|
await createProjectViaApi(ctx, { name: existingProjectName });
|
|
await goToDashboard(page);
|
|
|
|
// Open the project dropdown
|
|
await page.getByRole('button', { name: 'No Project' }).click();
|
|
|
|
// Type a search term that won't match any existing project
|
|
await page.getByTestId('client_dropdown_search').fill(searchText);
|
|
|
|
// Click "Create new Project"
|
|
await page.getByText('Create new Project').click();
|
|
|
|
// Verify the project name input is pre-filled with the search text
|
|
await expect(page.getByLabel('Project name')).toHaveValue(searchText);
|
|
|
|
// Complete project creation to verify full flow works
|
|
await Promise.all([
|
|
page.waitForResponse(
|
|
async (response) =>
|
|
response.url().includes('/projects') &&
|
|
response.request().method() === 'POST' &&
|
|
response.status() === 201 &&
|
|
(await response.json()).data.name === searchText
|
|
),
|
|
page.getByRole('button', { name: 'Create Project' }).click(),
|
|
]);
|
|
|
|
// The project dropdown should now show the newly created project
|
|
await expect(page.getByRole('button', { name: searchText })).toBeVisible();
|
|
});
|
|
|
|
test('test that adding a project and tag before starting timer works', async ({ page }) => {
|
|
const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000);
|
|
await goToDashboard(page);
|
|
|
|
// Create and select a tag first
|
|
await page.getByTestId('tag_dropdown').click();
|
|
await page.getByText('Create new tag').click();
|
|
await page.getByPlaceholder('Tag Name').fill(newTagName);
|
|
|
|
const [tagCreateResponse] = await Promise.all([
|
|
newTagResponse(page, { name: newTagName }),
|
|
page.getByRole('button', { name: 'Create Tag' }).click(),
|
|
]);
|
|
const tagId = (await tagCreateResponse.json()).data.id;
|
|
|
|
// Wait for tags query refetch (tag is auto-selected after creation)
|
|
await page.waitForResponse(
|
|
(response) => response.url().includes('/tags') && response.status() === 200
|
|
);
|
|
|
|
// Fill description and start
|
|
await page.getByTestId('time_entry_description').fill('Entry with tag');
|
|
await Promise.all([
|
|
newTimeEntryResponse(page, { description: 'Entry with tag', tags: [tagId] }),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerHasStarted(page);
|
|
|
|
await Promise.all([
|
|
stoppedTimeEntryResponse(page, { description: 'Entry with tag', tags: [tagId] }),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerIsStopped(page);
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// Project / Task selector dropdown
|
|
// Regression coverage for the virtualized + lookup-map refactor of
|
|
// TimeTrackerProjectTaskDropdown. The dropdown only (re)filters on open and on search
|
|
// change, so we wait for the dashboard prefetch to settle before opening it.
|
|
// ──────────────────────────────────────────────────
|
|
|
|
test.describe('Project Task Dropdown', () => {
|
|
test.describe.configure({ timeout: 60_000 });
|
|
|
|
test('test that a project far down a long list can be found via search and selected', async ({
|
|
page,
|
|
ctx,
|
|
}) => {
|
|
// Seed enough projects that the target sits outside the initially rendered window.
|
|
const seed = Math.floor(Math.random() * 100000);
|
|
const prefix = `VirtProj ${seed} `;
|
|
await Promise.all(
|
|
Array.from({ length: 30 }, (_, i) =>
|
|
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
|
|
)
|
|
);
|
|
const target = prefix + '27';
|
|
|
|
await goToDashboard(page);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await page.getByRole('button', { name: 'No Project' }).click();
|
|
await page.getByTestId('client_dropdown_search').fill(target);
|
|
await page.getByRole('option').filter({ hasText: target }).click();
|
|
|
|
// The trigger now reflects the selected project.
|
|
await expect(page.getByRole('button', { name: target })).toBeVisible();
|
|
});
|
|
|
|
test('test that expanding a project and selecting a task works', async ({ page, ctx }) => {
|
|
const seed = Math.floor(Math.random() * 100000);
|
|
const projectName = `ExpandProj ${seed}`;
|
|
const taskName = `ExpandTask ${seed}`;
|
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
|
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
|
|
|
|
await goToDashboard(page);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await page.getByRole('button', { name: 'No Project' }).click();
|
|
const projectOption = page.getByRole('option').filter({ hasText: projectName });
|
|
await expect(projectOption).toBeVisible();
|
|
|
|
// Expand the project's tasks via the "N Tasks" button, then select the task.
|
|
await projectOption.getByText(/Tasks/).click();
|
|
await page.getByText(taskName, { exact: true }).click();
|
|
|
|
// Scoped to the trigger button: the closing dropdown also contains the name while animating out.
|
|
await expect(
|
|
page.getByRole('button', { name: `${projectName} ${taskName}` })
|
|
).toBeVisible();
|
|
});
|
|
|
|
test('test that keyboard navigation selects a project', async ({ page, ctx }) => {
|
|
const seed = Math.floor(Math.random() * 100000);
|
|
const projectName = `KbProj ${seed}`;
|
|
await createProjectViaApi(ctx, { name: projectName });
|
|
|
|
await goToDashboard(page);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await page.getByRole('button', { name: 'No Project' }).click();
|
|
const search = page.getByTestId('client_dropdown_search');
|
|
// On open the search is focused and "No Project" is highlighted.
|
|
await expect(search).toBeFocused();
|
|
|
|
// Arrow down from "No Project" to the project, then select it with Enter.
|
|
await search.press('ArrowDown');
|
|
await search.press('Enter');
|
|
|
|
await expect(page.getByRole('button', { name: projectName })).toBeVisible();
|
|
});
|
|
|
|
test('test that search filters the dropdown by project and client name', async ({
|
|
page,
|
|
ctx,
|
|
}) => {
|
|
const seed = Math.floor(Math.random() * 100000);
|
|
const clientName = `FilterClient ${seed}`;
|
|
const alphaProject = `AlphaProj ${seed}`;
|
|
const betaProject = `BetaProj ${seed}`;
|
|
const client = await createClientViaApi(ctx, { name: clientName });
|
|
await createProjectViaApi(ctx, { name: alphaProject, client_id: client.id });
|
|
await createProjectViaApi(ctx, { name: betaProject });
|
|
|
|
await goToDashboard(page);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await page.getByRole('button', { name: 'No Project' }).click();
|
|
const search = page.getByTestId('client_dropdown_search');
|
|
const alphaOption = page.getByRole('option').filter({ hasText: alphaProject });
|
|
const betaOption = page.getByRole('option').filter({ hasText: betaProject });
|
|
|
|
// Both projects are visible before filtering.
|
|
await expect(alphaOption).toBeVisible();
|
|
await expect(betaOption).toBeVisible();
|
|
|
|
// Project-name search shows only the matching project.
|
|
await search.fill('AlphaProj');
|
|
await expect(alphaOption).toBeVisible();
|
|
await expect(betaOption).not.toBeVisible();
|
|
|
|
// Client-name search shows the project that belongs to that client.
|
|
await search.fill(clientName);
|
|
await expect(alphaOption).toBeVisible();
|
|
await expect(betaOption).not.toBeVisible();
|
|
});
|
|
|
|
test("test that searching by task name surfaces the task's project", async ({ page, ctx }) => {
|
|
const seed = Math.floor(Math.random() * 100000);
|
|
const projectWithTask = `TaskSearchProj ${seed}`;
|
|
const taskName = `Findable Task ${seed}`;
|
|
const unrelatedProject = `Unrelated Proj ${seed}`;
|
|
const project = await createProjectViaApi(ctx, { name: projectWithTask });
|
|
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
|
|
await createProjectViaApi(ctx, { name: unrelatedProject });
|
|
|
|
await goToDashboard(page);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await page.getByRole('button', { name: 'No Project' }).click();
|
|
await page.getByTestId('client_dropdown_search').fill(taskName);
|
|
|
|
// The project owning the task is shown (with the task), the unrelated project is not.
|
|
await expect(page.getByRole('option').filter({ hasText: projectWithTask })).toBeVisible();
|
|
await expect(page.getByText(taskName, { exact: true })).toBeVisible();
|
|
await expect(
|
|
page.getByRole('option').filter({ hasText: unrelatedProject })
|
|
).not.toBeVisible();
|
|
});
|
|
|
|
test('test that archived projects are hidden from the dropdown', async ({ page, ctx }) => {
|
|
const seed = Math.floor(Math.random() * 100000);
|
|
const activeProject = `ActiveProj ${seed}`;
|
|
const archivedProject = `ArchivedProj ${seed}`;
|
|
await createProjectViaApi(ctx, { name: activeProject });
|
|
const toArchive = await createProjectViaApi(ctx, { name: archivedProject });
|
|
await archiveProjectViaApi(ctx, toArchive);
|
|
|
|
await goToDashboard(page);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await page.getByRole('button', { name: 'No Project' }).click();
|
|
|
|
// Wait for the list to load, then confirm the archived project is filtered out.
|
|
await expect(page.getByRole('option').filter({ hasText: activeProject })).toBeVisible();
|
|
await expect(
|
|
page.getByRole('option').filter({ hasText: archivedProject })
|
|
).not.toBeVisible();
|
|
});
|
|
|
|
test('test that done tasks are hidden when expanding a project', async ({ page, ctx }) => {
|
|
const seed = Math.floor(Math.random() * 100000);
|
|
const projectName = `DoneTaskProj ${seed}`;
|
|
const activeTask = `Active Task ${seed}`;
|
|
const doneTask = `Done Task ${seed}`;
|
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
|
await createTaskViaApi(ctx, { name: activeTask, project_id: project.id });
|
|
const taskToFinish = await createTaskViaApi(ctx, {
|
|
name: doneTask,
|
|
project_id: project.id,
|
|
});
|
|
await markTaskDoneViaApi(ctx, taskToFinish);
|
|
|
|
await goToDashboard(page);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await page.getByRole('button', { name: 'No Project' }).click();
|
|
const projectOption = page.getByRole('option').filter({ hasText: projectName });
|
|
await expect(projectOption).toBeVisible();
|
|
await projectOption.getByText(/Tasks/).click();
|
|
|
|
// Only the active task shows; the done task is filtered out.
|
|
await expect(page.getByText(activeTask, { exact: true })).toBeVisible();
|
|
await expect(page.getByText(doneTask, { exact: true })).not.toBeVisible();
|
|
});
|
|
|
|
test('test that keyboard navigation can expand a project and select a task', async ({
|
|
page,
|
|
ctx,
|
|
}) => {
|
|
const seed = Math.floor(Math.random() * 100000);
|
|
const projectName = `KbTaskProj ${seed}`;
|
|
const taskName = `KbTask ${seed}`;
|
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
|
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
|
|
|
|
await goToDashboard(page);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await page.getByRole('button', { name: 'No Project' }).click();
|
|
const search = page.getByTestId('client_dropdown_search');
|
|
await expect(search).toBeFocused();
|
|
|
|
// No Project is highlighted on open: down to the project, right to expand its tasks,
|
|
// down to the task, Enter to select it.
|
|
await search.press('ArrowDown');
|
|
await search.press('ArrowRight');
|
|
await search.press('ArrowDown');
|
|
await search.press('Enter');
|
|
|
|
// Scoped to the trigger button: the closing dropdown also contains the name while animating out.
|
|
await expect(
|
|
page.getByRole('button', { name: `${projectName} ${taskName}` })
|
|
).toBeVisible();
|
|
});
|
|
|
|
test('test that pressing space selects the highlighted project', async ({ page, ctx }) => {
|
|
const seed = Math.floor(Math.random() * 100000);
|
|
const projectName = `SpaceProj ${seed}`;
|
|
await createProjectViaApi(ctx, { name: projectName });
|
|
|
|
await goToDashboard(page);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await page.getByRole('button', { name: 'No Project' }).click();
|
|
const search = page.getByTestId('client_dropdown_search');
|
|
await expect(search).toBeFocused();
|
|
|
|
// Arrow down from "No Project" to the project, then the space shortcut selects it.
|
|
await search.press('ArrowDown');
|
|
await search.press('Space');
|
|
|
|
await expect(page.getByRole('button', { name: projectName })).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test('test that simple mode hides the project, tag and billable controls', async ({ page }) => {
|
|
await goToDashboard(page);
|
|
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
|
// Project mode shows the project and billable controls
|
|
await expect(page.getByRole('button', { name: 'No Project' })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Non Billable' }).first()).toBeVisible();
|
|
|
|
// Switch to simple mode via the more options dropdown (client-side preference, no request)
|
|
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
|
await page.getByRole('menuitem', { name: 'Switch to simple mode' }).click();
|
|
|
|
// Simple mode is the project tracker without the project/tag/billable selectors; the
|
|
// description input and clock-in/out stay.
|
|
await expect(page.getByTestId('time_entry_description')).toBeEditable();
|
|
await expect(page.getByRole('button', { name: 'No Project' })).toHaveCount(0);
|
|
await expect(page.getByRole('button', { name: 'Non Billable' })).toHaveCount(0);
|
|
|
|
// Clock in and out
|
|
await Promise.all([
|
|
newTimeEntryResponse(page, { type: 'work' }),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerHasStarted(page);
|
|
await page.waitForTimeout(1500);
|
|
await Promise.all([
|
|
stoppedTimeEntryResponse(page, { type: 'work' }),
|
|
startOrStopTimerWithButton(page),
|
|
]);
|
|
await assertThatTimerIsStopped(page);
|
|
|
|
// Switch back to project mode: the controls return
|
|
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
|
await page.getByRole('menuitem', { name: 'Switch to project mode' }).click();
|
|
await expect(page.getByRole('button', { name: 'No Project' })).toBeVisible();
|
|
});
|