mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-15 09:13:16 +01:00
Add goals feature for personal goals with filters
This commit is contained in:
@@ -292,6 +292,16 @@ test.describe('Command Palette', () => {
|
||||
await expect(page.locator('[role="dialog"]').getByText('Create Tags')).toBeVisible();
|
||||
});
|
||||
|
||||
test('opens create goal modal', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
await openCommandPalette(page);
|
||||
await searchInCommandPalette(page, 'Create Goal');
|
||||
await selectCommand(page, 'Create Goal');
|
||||
await expect(
|
||||
page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Goal' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('opens invite member modal', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
await openCommandPalette(page);
|
||||
@@ -466,6 +476,9 @@ test.describe('Employee Command Palette Restrictions', () => {
|
||||
employee.page.getByRole('option', { name: 'Invite Member' })
|
||||
).not.toBeVisible();
|
||||
|
||||
// Employees can set personal goals for themselves
|
||||
await expect(employee.page.getByRole('option', { name: 'Create Goal' })).toBeVisible();
|
||||
|
||||
// Should still see Create Time Entry (employees can create time entries)
|
||||
await expect(
|
||||
employee.page.getByRole('option', { name: 'Create Time Entry' })
|
||||
|
||||
688
e2e/goals.spec.ts
Normal file
688
e2e/goals.spec.ts
Normal file
@@ -0,0 +1,688 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import {
|
||||
createClientViaApi,
|
||||
createGoalViaApi,
|
||||
createProjectViaApi,
|
||||
createProjectMemberViaApi,
|
||||
setupTestContext,
|
||||
createRunningTimeEntryWithStartViaApi,
|
||||
createTagViaApi,
|
||||
createTaskViaApi,
|
||||
createTimeEntryViaApi,
|
||||
deleteClientViaApi,
|
||||
deleteProjectViaApi,
|
||||
deleteTagViaApi,
|
||||
deleteTaskViaApi,
|
||||
updateGoalViaApi,
|
||||
updateOrganizationSettingViaApi,
|
||||
updateUserProfileViaApi,
|
||||
} from './utils/api';
|
||||
import {
|
||||
selectTimezone,
|
||||
selectWeekStart,
|
||||
timezoneField,
|
||||
weekStartField,
|
||||
} from './utils/userSettingsFields';
|
||||
|
||||
async function goToGoalsOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/goals');
|
||||
await expect(page.getByTestId('goals_view')).toBeVisible();
|
||||
}
|
||||
|
||||
function goalRow(page: Page, goalId: string) {
|
||||
return page.getByTestId('goal_row_' + goalId);
|
||||
}
|
||||
|
||||
function goalResponse(page: Page, method: 'POST' | 'PUT' | 'DELETE', status: number) {
|
||||
return page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/goals') &&
|
||||
response.request().method() === method &&
|
||||
response.status() === status
|
||||
);
|
||||
}
|
||||
|
||||
test('test that the goals page shows an empty state and the sidebar link works', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
await page.getByRole('link', { name: 'Goals' }).first().click();
|
||||
await expect(page).toHaveURL(/\/goals$/);
|
||||
await expect(page.getByTestId('goal_table')).toContainText('No goals found');
|
||||
await expect(page.getByRole('button', { name: 'Create your first goal' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that creating a goal via the modal works and shows zero progress', async ({ page }) => {
|
||||
const goalName = 'Deep work ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToGoalsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Goal' }).click();
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill(goalName);
|
||||
await page.getByTestId('duration_seconds_input').fill('10h');
|
||||
await page.getByTestId('duration_seconds_input').press('Tab');
|
||||
await page.getByRole('combobox', { name: 'Period' }).click();
|
||||
await page.getByRole('option', { name: 'per day' }).click();
|
||||
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'POST', 201),
|
||||
page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
|
||||
]);
|
||||
const body = await response.json();
|
||||
expect(body.data.name).toBe(goalName);
|
||||
expect(body.data.target_seconds).toBe(36000);
|
||||
expect(body.data.period).toBe('day');
|
||||
expect(body.data.comparison).toBe('at_least');
|
||||
expect(body.data.filters.time_entry_type).toBe('work');
|
||||
expect(body.data.type).toBe('personal');
|
||||
expect(body.data.is_archived).toBe(false);
|
||||
|
||||
const row = goalRow(page, body.data.id);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.getByTestId('goal_name')).toHaveText(goalName);
|
||||
await expect(row.getByTestId('goal_target_description')).toHaveText(
|
||||
'At least 10h 00min per day'
|
||||
);
|
||||
await expect(row.getByTestId('goal_tracked_time')).toHaveText('0h 00min');
|
||||
await expect(row).toContainText('of 10h 00min');
|
||||
await expect(row).toContainText('In progress');
|
||||
});
|
||||
|
||||
test('test that the create goal modal validates name and target', async ({ page }) => {
|
||||
await goToGoalsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Goal' }).click();
|
||||
await page.getByRole('button', { name: 'Create Goal', exact: true }).last().click();
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'name' })).toBeVisible();
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'target time' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that goal progress reflects tracked time entries that match the filters', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const project = await createProjectViaApi(ctx, { name: 'Goal Project', is_public: true });
|
||||
const otherProject = await createProjectViaApi(ctx, { name: 'Other Project', is_public: true });
|
||||
await createTimeEntryViaApi(ctx, { duration: '2h', projectId: project.id });
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h 30min', projectId: otherProject.id });
|
||||
await createTimeEntryViaApi(ctx, { duration: '45min' });
|
||||
|
||||
const projectGoal = await createGoalViaApi(ctx, {
|
||||
name: 'Project goal',
|
||||
target_seconds: 4 * 3600,
|
||||
period: 'day',
|
||||
filters: { project_ids: [project.id], time_entry_type: 'work' },
|
||||
});
|
||||
const allGoal = await createGoalViaApi(ctx, {
|
||||
name: 'All time goal',
|
||||
target_seconds: 4 * 3600,
|
||||
period: 'day',
|
||||
});
|
||||
const lessThanGoal = await createGoalViaApi(ctx, {
|
||||
name: 'Less than goal',
|
||||
comparison: 'less_than',
|
||||
target_seconds: 3600,
|
||||
period: 'day',
|
||||
filters: { project_ids: [project.id], time_entry_type: 'work' },
|
||||
});
|
||||
|
||||
expect(projectGoal.progress.tracked_seconds).toBe(2 * 3600);
|
||||
expect(allGoal.progress.tracked_seconds).toBe(2 * 3600 + 90 * 60 + 45 * 60);
|
||||
expect(lessThanGoal.progress.status).toBe('exceeded');
|
||||
|
||||
await goToGoalsOverview(page);
|
||||
await expect(goalRow(page, projectGoal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
'2h 00min'
|
||||
);
|
||||
await expect(goalRow(page, projectGoal.id)).toContainText('Goal Project');
|
||||
await expect(goalRow(page, projectGoal.id)).toContainText('In progress');
|
||||
await expect(goalRow(page, allGoal.id).getByTestId('goal_tracked_time')).toHaveText('4h 15min');
|
||||
await expect(goalRow(page, allGoal.id)).toContainText('Achieved');
|
||||
await expect(goalRow(page, allGoal.id)).toContainText('All time entries');
|
||||
await expect(goalRow(page, lessThanGoal.id)).toContainText('Exceeded');
|
||||
await expect(goalRow(page, lessThanGoal.id)).toContainText('Less than 1h 00min per day');
|
||||
});
|
||||
|
||||
async function openCreateGoalModal(page: Page, name: string) {
|
||||
await goToGoalsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Goal' }).click();
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill(name);
|
||||
await page.getByTestId('duration_seconds_input').fill('10h');
|
||||
await page.getByTestId('duration_seconds_input').press('Tab');
|
||||
}
|
||||
|
||||
async function submitCreateGoalModal(page: Page) {
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'POST', 201),
|
||||
page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
|
||||
]);
|
||||
return (await response.json()).data;
|
||||
}
|
||||
|
||||
async function selectGoalFilterOption(
|
||||
page: Page,
|
||||
filter: string,
|
||||
searchPlaceholder: string,
|
||||
option: string
|
||||
) {
|
||||
await page.getByTestId('goal_filter_' + filter).click();
|
||||
await page.getByRole('option', { name: option }).click();
|
||||
await page.getByPlaceholder(searchPlaceholder).press('Escape');
|
||||
}
|
||||
|
||||
test('test that the task filter of a goal only counts entries of the selected tasks', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const project = await createProjectViaApi(ctx, {
|
||||
name: 'Task filter project',
|
||||
is_public: true,
|
||||
});
|
||||
const task = await createTaskViaApi(ctx, { name: 'Counted task', project_id: project.id });
|
||||
const otherTask = await createTaskViaApi(ctx, { name: 'Other task', project_id: project.id });
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h', projectId: project.id, taskId: task.id });
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
duration: '2h',
|
||||
projectId: project.id,
|
||||
taskId: otherTask.id,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, { duration: '30min', projectId: project.id });
|
||||
|
||||
await openCreateGoalModal(page, 'Task goal');
|
||||
await selectGoalFilterOption(page, 'task_ids', 'Search for a Task...', 'Counted task');
|
||||
await expect(page.getByTestId('goal_filter_task_ids')).toHaveText(/Tasks\s*1/);
|
||||
const goal = await submitCreateGoalModal(page);
|
||||
|
||||
expect(goal.filters.task_ids).toEqual([task.id]);
|
||||
expect(goal.progress.tracked_seconds).toBe(3600);
|
||||
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('1h 00min');
|
||||
});
|
||||
|
||||
test('test that the client filter of a goal only counts entries of projects of the selected clients', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const client = await createClientViaApi(ctx, { name: 'Counted client' });
|
||||
const otherClient = await createClientViaApi(ctx, { name: 'Other client' });
|
||||
const project = await createProjectViaApi(ctx, {
|
||||
name: 'Client project',
|
||||
client_id: client.id,
|
||||
is_public: true,
|
||||
});
|
||||
const otherProject = await createProjectViaApi(ctx, {
|
||||
name: 'Other client project',
|
||||
client_id: otherClient.id,
|
||||
is_public: true,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h', projectId: project.id });
|
||||
await createTimeEntryViaApi(ctx, { duration: '2h', projectId: otherProject.id });
|
||||
await createTimeEntryViaApi(ctx, { duration: '30min' });
|
||||
|
||||
await openCreateGoalModal(page, 'Client goal');
|
||||
await selectGoalFilterOption(page, 'client_ids', 'Search for a Client...', 'Counted client');
|
||||
await expect(page.getByTestId('goal_filter_client_ids')).toHaveText(/Clients\s*1/);
|
||||
const goal = await submitCreateGoalModal(page);
|
||||
|
||||
expect(goal.filters.client_ids).toEqual([client.id]);
|
||||
expect(goal.progress.tracked_seconds).toBe(3600);
|
||||
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('1h 00min');
|
||||
});
|
||||
|
||||
test('test that the tag filter of a goal respects the tag match type', async ({ page, ctx }) => {
|
||||
const tag = await createTagViaApi(ctx, { name: 'Counted tag' });
|
||||
const otherTag = await createTagViaApi(ctx, { name: 'Other tag' });
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h', tags: [tag.id] });
|
||||
await createTimeEntryViaApi(ctx, { duration: '2h', tags: [otherTag.id] });
|
||||
await createTimeEntryViaApi(ctx, { duration: '30min' });
|
||||
|
||||
await openCreateGoalModal(page, 'Contains tag goal');
|
||||
await selectGoalFilterOption(page, 'tag_ids', 'Search for a Tag...', 'Counted tag');
|
||||
await expect(page.getByTestId('goal_filter_tag_ids')).toHaveText(/Tags\s*1/);
|
||||
const containsGoal = await submitCreateGoalModal(page);
|
||||
expect(containsGoal.filters.tag_ids).toEqual([tag.id]);
|
||||
expect(containsGoal.filters.tag_match_type).toBe('contains');
|
||||
expect(containsGoal.progress.tracked_seconds).toBe(3600);
|
||||
|
||||
await openCreateGoalModal(page, 'Not contains tag goal');
|
||||
await page.getByTestId('goal_filter_tag_ids').click();
|
||||
await page.getByRole('radio', { name: 'Does Not Contain' }).click();
|
||||
await page.getByRole('option', { name: 'Counted tag' }).click();
|
||||
await page.getByPlaceholder('Search for a Tag...').press('Escape');
|
||||
const notContainsGoal = await submitCreateGoalModal(page);
|
||||
expect(notContainsGoal.filters.tag_ids).toEqual([tag.id]);
|
||||
expect(notContainsGoal.filters.tag_match_type).toBe('not_contains');
|
||||
// Entries with other tags and entries without tags count
|
||||
expect(notContainsGoal.progress.tracked_seconds).toBe(2 * 3600 + 30 * 60);
|
||||
|
||||
await expect(goalRow(page, containsGoal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
'1h 00min'
|
||||
);
|
||||
await expect(goalRow(page, notContainsGoal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
'2h 30min'
|
||||
);
|
||||
});
|
||||
|
||||
test('test that the billable filter of a goal only counts entries with the selected billable status', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h', billable: true });
|
||||
await createTimeEntryViaApi(ctx, { duration: '2h', billable: false });
|
||||
|
||||
await openCreateGoalModal(page, 'Billable goal');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
const billableGoal = await submitCreateGoalModal(page);
|
||||
expect(billableGoal.filters.billable).toBe(true);
|
||||
expect(billableGoal.progress.tracked_seconds).toBe(3600);
|
||||
|
||||
await openCreateGoalModal(page, 'Non billable goal');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
|
||||
await page.getByRole('option', { name: 'Non Billable' }).click();
|
||||
const nonBillableGoal = await submitCreateGoalModal(page);
|
||||
expect(nonBillableGoal.filters.billable).toBe(false);
|
||||
expect(nonBillableGoal.progress.tracked_seconds).toBe(2 * 3600);
|
||||
|
||||
await expect(goalRow(page, billableGoal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
'1h 00min'
|
||||
);
|
||||
await expect(goalRow(page, nonBillableGoal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
'2h 00min'
|
||||
);
|
||||
});
|
||||
|
||||
test('test that the type filter of a goal only counts entries of the selected type', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h' });
|
||||
await createTimeEntryViaApi(ctx, { duration: '30min', type: 'break' });
|
||||
|
||||
await openCreateGoalModal(page, 'Break goal');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Work time' }).click();
|
||||
await page.getByRole('option', { name: 'Breaks' }).click();
|
||||
const breakGoal = await submitCreateGoalModal(page);
|
||||
expect(breakGoal.filters.time_entry_type).toBe('break');
|
||||
expect(breakGoal.progress.tracked_seconds).toBe(30 * 60);
|
||||
|
||||
await openCreateGoalModal(page, 'Work and break goal');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Work time' }).click();
|
||||
await page.getByRole('option', { name: 'Both' }).click();
|
||||
const bothGoal = await submitCreateGoalModal(page);
|
||||
expect(bothGoal.filters.time_entry_type).toBeNull();
|
||||
expect(bothGoal.progress.tracked_seconds).toBe(3600 + 30 * 60);
|
||||
|
||||
await expect(goalRow(page, breakGoal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
'0h 30min'
|
||||
);
|
||||
await expect(goalRow(page, bothGoal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
'1h 30min'
|
||||
);
|
||||
});
|
||||
|
||||
test('test that a running time entry counts towards the goal progress', async ({ page, ctx }) => {
|
||||
const start = new Date(Date.now() - 30 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
await createRunningTimeEntryWithStartViaApi(ctx, 'Running work', start);
|
||||
// Close to midnight UTC the entry would start on the previous day, so the day of the goal
|
||||
// is moved to a timezone (without daylight saving time) where it is late morning instead
|
||||
const utcHour = new Date().getUTCHours();
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Running goal',
|
||||
target_seconds: 3600,
|
||||
period: 'day',
|
||||
timezone: utcHour >= 2 && utcHour < 22 ? 'UTC' : 'Asia/Tokyo',
|
||||
});
|
||||
expect(goal.progress.tracked_seconds).toBeGreaterThanOrEqual(30 * 60);
|
||||
|
||||
await goToGoalsOverview(page);
|
||||
// The entry keeps running while the page loads
|
||||
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
/^0h 3[0-2]min$/
|
||||
);
|
||||
});
|
||||
|
||||
test('test that editing a goal via the modal works', async ({ page, ctx }) => {
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Old goal name',
|
||||
target_seconds: 3600,
|
||||
period: 'week',
|
||||
});
|
||||
await goToGoalsOverview(page);
|
||||
await page.locator("[aria-label='Actions for Goal Old goal name']").click();
|
||||
await page.locator("[aria-label='Edit Goal Old goal name']").click();
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill('New goal name');
|
||||
await page.getByRole('combobox', { name: 'Target type' }).click();
|
||||
await page.getByRole('option', { name: 'Less than' }).click();
|
||||
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'PUT', 200),
|
||||
page.getByRole('button', { name: 'Update Goal' }).click(),
|
||||
]);
|
||||
const body = await response.json();
|
||||
expect(body.data.name).toBe('New goal name');
|
||||
expect(body.data.comparison).toBe('less_than');
|
||||
|
||||
const row = goalRow(page, goal.id);
|
||||
await expect(row.getByTestId('goal_name')).toHaveText('New goal name');
|
||||
await expect(row).toContainText('Less than 1h 00min per week');
|
||||
await expect(row).toContainText('On track');
|
||||
});
|
||||
|
||||
test('test that a goal whose filters point to deleted entities can still be edited', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const keptProject = await createProjectViaApi(ctx, { name: 'Kept project' });
|
||||
const deletedProject = await createProjectViaApi(ctx, { name: 'Deleted project' });
|
||||
const keptTask = await createTaskViaApi(ctx, { name: 'Kept task', project_id: keptProject.id });
|
||||
const deletedTask = await createTaskViaApi(ctx, {
|
||||
name: 'Deleted task',
|
||||
project_id: keptProject.id,
|
||||
});
|
||||
const keptTag = await createTagViaApi(ctx, { name: 'Kept tag' });
|
||||
const deletedTag = await createTagViaApi(ctx, { name: 'Deleted tag' });
|
||||
const deletedClient = await createClientViaApi(ctx, { name: 'Deleted client' });
|
||||
const filters = {
|
||||
project_ids: [keptProject.id, deletedProject.id],
|
||||
task_ids: [keptTask.id, deletedTask.id],
|
||||
// "none" is no entity, it has to survive the cleanup
|
||||
tag_ids: ['none', keptTag.id, deletedTag.id],
|
||||
// Every client of this filter gets deleted
|
||||
client_ids: [deletedClient.id],
|
||||
time_entry_type: 'work' as const,
|
||||
};
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Stale filters',
|
||||
target_seconds: 3600,
|
||||
filters,
|
||||
});
|
||||
await deleteTaskViaApi(ctx, deletedTask.id);
|
||||
await deleteProjectViaApi(ctx, deletedProject.id);
|
||||
await deleteTagViaApi(ctx, deletedTag.id);
|
||||
await deleteClientViaApi(ctx, deletedClient.id);
|
||||
|
||||
// Resending deleted IDs fails; unrelated edits can omit filters to preserve the scope.
|
||||
const rejected = await updateGoalViaApi(ctx, goal.id, { filters });
|
||||
expect(rejected.status()).toBe(422);
|
||||
const errors = (await rejected.json()).errors;
|
||||
expect(Object.keys(errors).sort()).toEqual([
|
||||
'filters.client_ids.0',
|
||||
'filters.project_ids.1',
|
||||
'filters.tag_ids.2',
|
||||
'filters.task_ids.1',
|
||||
]);
|
||||
|
||||
await goToGoalsOverview(page);
|
||||
await page.locator("[aria-label='Actions for Goal Stale filters']").click();
|
||||
await page.locator("[aria-label='Edit Goal Stale filters']").click();
|
||||
// Opening the form must preserve the scope, even after lists have loaded.
|
||||
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*2/);
|
||||
await expect(page.getByTestId('goal_filter_task_ids')).toHaveText(/Tasks\s*2/);
|
||||
await expect(page.getByTestId('goal_filter_tag_ids')).toHaveText(/Tags\s*3/);
|
||||
await expect(page.getByTestId('goal_filter_client_ids')).toHaveText(/Clients\s*1/);
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Preserved filters');
|
||||
await page.getByRole('button', { name: 'Update Goal' }).click();
|
||||
await expect(page.getByTestId('goal_unavailable_filters')).toContainText(
|
||||
'This will count all clients'
|
||||
);
|
||||
const [preservedResponse] = await Promise.all([
|
||||
goalResponse(page, 'PUT', 200),
|
||||
page.getByRole('button', { name: 'Keep existing filters' }).click(),
|
||||
]);
|
||||
expect(preservedResponse.request().postDataJSON()).not.toHaveProperty('filters');
|
||||
expect((await preservedResponse.json()).data.filters).toMatchObject(filters);
|
||||
|
||||
await page.locator("[aria-label='Actions for Goal Preserved filters']").click();
|
||||
await page.locator("[aria-label='Edit Goal Preserved filters']").click();
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Cleaned filters');
|
||||
await page.getByRole('button', { name: 'Update Goal' }).click();
|
||||
await expect(page.getByTestId('goal_unavailable_filters')).toBeVisible();
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'PUT', 200),
|
||||
page.getByRole('button', { name: 'Remove unavailable items' }).click(),
|
||||
]);
|
||||
const body = await response.json();
|
||||
expect(body.data.name).toBe('Cleaned filters');
|
||||
expect(body.data.filters.project_ids).toEqual([keptProject.id]);
|
||||
expect(body.data.filters.task_ids).toEqual([keptTask.id]);
|
||||
expect(body.data.filters.tag_ids).toEqual(['none', keptTag.id]);
|
||||
// A filter whose entities are all gone is dropped, the goal no longer filters by client
|
||||
expect(body.data.filters.client_ids).toBeNull();
|
||||
await expect(goalRow(page, goal.id).getByTestId('goal_name')).toHaveText('Cleaned filters');
|
||||
});
|
||||
|
||||
test('losing access to a private project warns without silently changing a personal goal', async ({
|
||||
ctx,
|
||||
employee,
|
||||
}) => {
|
||||
test.setTimeout(120 * 1000);
|
||||
const project = await createProjectViaApi(ctx, {
|
||||
name: 'Private goal project',
|
||||
is_public: false,
|
||||
});
|
||||
const membership = await createProjectMemberViaApi(ctx, project.id, {
|
||||
member_id: employee.memberId,
|
||||
});
|
||||
const employeeCtx = {
|
||||
...(await setupTestContext(employee.page)),
|
||||
orgId: ctx.orgId,
|
||||
memberId: employee.memberId,
|
||||
};
|
||||
const goal = await createGoalViaApi(employeeCtx, {
|
||||
name: 'Private project goal',
|
||||
target_seconds: 3600,
|
||||
filters: { project_ids: [project.id], time_entry_type: 'work' },
|
||||
});
|
||||
const removed = await ctx.request.delete(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/project-members/${membership.id}`
|
||||
);
|
||||
expect(removed.status()).toBe(204);
|
||||
// The project still exists, but the employee's selectable list no longer contains it.
|
||||
const projects = await employeeCtx.request.get(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects?archived=all`
|
||||
);
|
||||
expect(projects.status()).toBe(200);
|
||||
expect((await projects.json()).data.map((item: { id: string }) => item.id)).not.toContain(
|
||||
project.id
|
||||
);
|
||||
|
||||
const page = employee.page;
|
||||
await goToGoalsOverview(page);
|
||||
await page.locator("[aria-label='Actions for Goal Private project goal']").click();
|
||||
await page.locator("[aria-label='Edit Goal Private project goal']").click();
|
||||
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/);
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Still scoped');
|
||||
await page.getByRole('button', { name: 'Update Goal' }).click();
|
||||
await expect(page.getByTestId('goal_unavailable_filters')).toContainText(
|
||||
'Unavailable projects: 1'
|
||||
);
|
||||
await expect(page.getByTestId('goal_unavailable_filters')).toContainText(
|
||||
'This will count all projects'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click();
|
||||
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/);
|
||||
await page.getByRole('button', { name: 'Update Goal' }).click();
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'PUT', 200),
|
||||
page.getByRole('button', { name: 'Keep existing filters' }).click(),
|
||||
]);
|
||||
expect(response.request().postDataJSON()).not.toHaveProperty('filters');
|
||||
expect((await response.json()).data.filters.project_ids).toEqual([project.id]);
|
||||
await expect(goalRow(page, goal.id).getByTestId('goal_name')).toHaveText('Still scoped');
|
||||
});
|
||||
|
||||
test('test that archiving a goal moves it to the archived tab and back', async ({ page, ctx }) => {
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Goal to archive',
|
||||
target_seconds: 3600,
|
||||
});
|
||||
await goToGoalsOverview(page);
|
||||
await expect(goalRow(page, goal.id)).toBeVisible();
|
||||
|
||||
await page.locator("[aria-label='Actions for Goal Goal to archive']").click();
|
||||
await Promise.all([
|
||||
goalResponse(page, 'PUT', 200),
|
||||
page.locator("[aria-label='Archive Goal Goal to archive']").click(),
|
||||
]);
|
||||
await expect(goalRow(page, goal.id)).not.toBeVisible();
|
||||
await expect(page.getByTestId('goal_table')).toContainText('No goals found');
|
||||
|
||||
await page.getByRole('tab', { name: 'Archived' }).click();
|
||||
const archivedRow = goalRow(page, goal.id);
|
||||
await expect(archivedRow).toBeVisible();
|
||||
await expect(archivedRow.getByTestId('goal_archived_badge')).toBeVisible();
|
||||
|
||||
await page.locator("[aria-label='Actions for Goal Goal to archive']").click();
|
||||
await Promise.all([
|
||||
goalResponse(page, 'PUT', 200),
|
||||
page.locator("[aria-label='Unarchive Goal Goal to archive']").click(),
|
||||
]);
|
||||
await expect(goalRow(page, goal.id)).not.toBeVisible();
|
||||
await page.getByRole('tab', { name: 'Active' }).click();
|
||||
await expect(goalRow(page, goal.id)).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that the period settings of a goal can be changed in the accordion', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Goal with timezone',
|
||||
target_seconds: 3600,
|
||||
period: 'week',
|
||||
timezone: 'Asia/Tokyo',
|
||||
week_start: 'friday',
|
||||
});
|
||||
await goToGoalsOverview(page);
|
||||
await page.locator("[aria-label='Actions for Goal Goal with timezone']").click();
|
||||
await page.locator("[aria-label='Edit Goal Goal with timezone']").click();
|
||||
await page.getByTestId('goal_period_settings').click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(timezoneField(dialog)).toHaveText('Asia/Tokyo');
|
||||
await expect(weekStartField(dialog)).toHaveText('Friday');
|
||||
|
||||
await selectTimezone(page, dialog, 'America/New_York', 'new york');
|
||||
await selectWeekStart(page, dialog, 'Sunday');
|
||||
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'PUT', 200),
|
||||
page.getByRole('button', { name: 'Update Goal' }).click(),
|
||||
]);
|
||||
const body = await response.json();
|
||||
expect(body.data.id).toBe(goal.id);
|
||||
expect(body.data.timezone).toBe('America/New_York');
|
||||
expect(body.data.week_start).toBe('sunday');
|
||||
});
|
||||
|
||||
test('test that the period settings of a new goal default to the user settings', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Same UTC offset as the browser, so the timezone mismatch modal stays closed
|
||||
await updateUserProfileViaApi(ctx, { timezone: 'Atlantic/Reykjavik', week_start: 'wednesday' });
|
||||
await goToGoalsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Goal' }).click();
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Goal with defaults');
|
||||
await page.getByTestId('duration_seconds_input').fill('10h');
|
||||
await page.getByTestId('duration_seconds_input').press('Tab');
|
||||
await page.getByTestId('goal_period_settings').click();
|
||||
await expect(timezoneField(page.getByRole('dialog'))).toHaveText('Atlantic/Reykjavik');
|
||||
await expect(weekStartField(page.getByRole('dialog'))).toHaveText('Wednesday');
|
||||
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'POST', 201),
|
||||
page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
|
||||
]);
|
||||
const body = await response.json();
|
||||
expect(body.data.timezone).toBe('Atlantic/Reykjavik');
|
||||
expect(body.data.week_start).toBe('wednesday');
|
||||
});
|
||||
|
||||
test('test that the period settings of a new goal can be set in the create modal', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToGoalsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Goal' }).click();
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Goal in New York');
|
||||
await page.getByTestId('duration_seconds_input').fill('10h');
|
||||
await page.getByTestId('duration_seconds_input').press('Tab');
|
||||
await page.getByTestId('goal_period_settings').click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await selectTimezone(page, dialog, 'America/New_York', 'new york');
|
||||
await selectWeekStart(page, dialog, 'Saturday');
|
||||
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'POST', 201),
|
||||
page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
|
||||
]);
|
||||
const body = await response.json();
|
||||
expect(body.data.name).toBe('Goal in New York');
|
||||
expect(body.data.timezone).toBe('America/New_York');
|
||||
expect(body.data.week_start).toBe('saturday');
|
||||
});
|
||||
|
||||
test('test that the search of a goal filter can be used with the keyboard', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await createProjectViaApi(ctx, { name: 'Alpha keyboard project', is_public: true });
|
||||
await createProjectViaApi(ctx, { name: 'Beta keyboard project', is_public: true });
|
||||
await goToGoalsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Goal' }).click();
|
||||
await page.getByTestId('goal_filter_project_ids').click();
|
||||
|
||||
const search = page.getByPlaceholder('Search for a Project...');
|
||||
await search.click();
|
||||
await search.pressSequentially('alpha');
|
||||
await expect(search).toHaveValue('alpha');
|
||||
await expect(page.getByRole('option', { name: 'Beta keyboard project' })).toBeHidden();
|
||||
|
||||
// The arrow keys highlight an option while the focus stays in the search
|
||||
const alpha = page.getByRole('option', { name: 'Alpha keyboard project' });
|
||||
await search.press('ArrowDown');
|
||||
await expect(alpha).toHaveAttribute('data-highlighted', '');
|
||||
await search.press('Enter');
|
||||
await expect(search).toBeFocused();
|
||||
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/);
|
||||
|
||||
// Escape closes the dropdown but keeps the modal and the selection
|
||||
await search.press('Escape');
|
||||
await expect(search).toBeHidden();
|
||||
await expect(page.getByPlaceholder('e.g. Deep work on Project X')).toBeVisible();
|
||||
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/);
|
||||
});
|
||||
|
||||
test('test that deleting a goal via the more options dropdown works', async ({ page, ctx }) => {
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Goal to delete',
|
||||
target_seconds: 3600,
|
||||
});
|
||||
await goToGoalsOverview(page);
|
||||
await expect(goalRow(page, goal.id)).toBeVisible();
|
||||
await page.locator("[aria-label='Actions for Goal Goal to delete']").click();
|
||||
await Promise.all([
|
||||
goalResponse(page, 'DELETE', 204),
|
||||
page.locator("[aria-label='Delete Goal Goal to delete']").click(),
|
||||
]);
|
||||
await expect(goalRow(page, goal.id)).not.toBeVisible();
|
||||
await expect(page.getByTestId('goal_table')).toContainText('No goals found');
|
||||
});
|
||||
|
||||
test.describe('employee', () => {
|
||||
test('employee can create and see their own goals', async ({ employee }) => {
|
||||
await goToGoalsOverview(employee.page);
|
||||
await employee.page.getByRole('button', { name: 'Create Goal' }).click();
|
||||
await employee.page.getByPlaceholder('e.g. Deep work on Project X').fill('Employee goal');
|
||||
await employee.page.getByTestId('duration_seconds_input').fill('2h');
|
||||
await employee.page.getByTestId('duration_seconds_input').press('Tab');
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(employee.page, 'POST', 201),
|
||||
employee.page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
|
||||
]);
|
||||
const body = await response.json();
|
||||
expect(body.data.type).toBe('personal');
|
||||
expect(body.data.member_id).toBe(employee.memberId);
|
||||
await expect(goalRow(employee.page, body.data.id)).toContainText('Employee goal');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
} from './utils/mailpit';
|
||||
import { getCurrentUserViaApi } from './utils/api';
|
||||
import { registerUser } from './utils/members';
|
||||
import {
|
||||
selectTimezone,
|
||||
selectWeekStart,
|
||||
timezoneField,
|
||||
weekStartField,
|
||||
} from './utils/userSettingsFields';
|
||||
import type { Page } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
@@ -40,20 +46,51 @@ test('user name can be updated', async ({ page }) => {
|
||||
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME');
|
||||
});
|
||||
|
||||
// The tests below pick UTC+0 timezones: the browser runs in UTC, and any other offset
|
||||
// opens the timezone mismatch modal after the reload.
|
||||
test('timezone change persists across reload', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
await page.getByLabel('Timezone').selectOption('America/New_York');
|
||||
await selectTimezone(page, page, 'Africa/Sao_Tome', 'sao tome');
|
||||
await saveProfileForm(page);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Timezone')).toHaveValue('America/New_York');
|
||||
await expect(timezoneField(page)).toHaveText('Africa/Sao_Tome');
|
||||
});
|
||||
|
||||
test('timezone can be selected with the keyboard', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
await expect(timezoneField(page)).toBeEnabled();
|
||||
await timezoneField(page).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const search = page.getByRole('combobox', { name: 'Search timezones' });
|
||||
await expect(search).toBeFocused();
|
||||
await search.fill('Africa/Abid');
|
||||
await expect(page.getByRole('option')).toHaveText(['Africa/Abidjan']);
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(timezoneField(page)).toHaveText('Africa/Abidjan');
|
||||
await saveProfileForm(page);
|
||||
await page.reload();
|
||||
await expect(timezoneField(page)).toHaveText('Africa/Abidjan');
|
||||
});
|
||||
|
||||
test('timezone search shows an empty state when nothing matches', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
await timezoneField(page).click();
|
||||
const search = page.getByRole('combobox', { name: 'Search timezones' });
|
||||
// Opening starts with an empty search, not one prefilled with the current timezone
|
||||
await expect(search).toHaveValue('');
|
||||
await expect(page.getByRole('option').nth(1)).toBeVisible();
|
||||
await search.fill('Atlantis');
|
||||
await expect(page.getByText('No timezone found.')).toBeVisible();
|
||||
await expect(page.getByRole('option')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('week-start change persists across reload', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
await page.getByLabel('Start of the week').selectOption('sunday');
|
||||
await selectWeekStart(page, page, 'Sunday');
|
||||
await saveProfileForm(page);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
|
||||
await expect(weekStartField(page)).toHaveText('Sunday');
|
||||
});
|
||||
|
||||
test('still-running email notification setting persists across reload', async ({ page }) => {
|
||||
@@ -565,7 +602,10 @@ test('test that theme can be changed to dark and light', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
|
||||
// The theme select is a Reka UI combobox (button), not a native <select>
|
||||
const themeSelect = page.locator('button[role="combobox"]');
|
||||
const themeSelect = page
|
||||
.getByRole('heading', { name: 'Theme', exact: true })
|
||||
.locator('xpath=ancestor::*[descendant::form][1]')
|
||||
.getByRole('combobox');
|
||||
|
||||
// Change theme to dark
|
||||
await themeSelect.click();
|
||||
@@ -591,7 +631,7 @@ test('test that theme can be changed to dark and light', async ({ page }) => {
|
||||
await expect(page.locator('html')).toHaveClass(/light/);
|
||||
|
||||
// Reset to system
|
||||
await page.locator('button[role="combobox"]').click();
|
||||
await themeSelect.click();
|
||||
await page.getByRole('option', { name: 'System' }).click();
|
||||
await expect(page.getByText('System default:')).toBeVisible();
|
||||
});
|
||||
|
||||
110
e2e/utils/api.ts
110
e2e/utils/api.ts
@@ -990,3 +990,113 @@ export async function getInvoicesViaApi(ctx: TestContext) {
|
||||
paid_date: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function createGoalViaApi(
|
||||
ctx: TestContext,
|
||||
data: {
|
||||
name: string;
|
||||
type?: 'personal' | 'organization';
|
||||
comparison?: 'at_least' | 'less_than';
|
||||
target_seconds: number;
|
||||
period?: 'day' | 'week' | 'month';
|
||||
// Member the goal is for, null counts every member (organization goals only)
|
||||
member_id?: string | null;
|
||||
timezone?: string;
|
||||
week_start?: string;
|
||||
filters?: {
|
||||
member_ids?: string[] | null;
|
||||
project_ids?: string[] | null;
|
||||
task_ids?: string[] | null;
|
||||
tag_ids?: string[] | null;
|
||||
tag_match_type?: 'contains' | 'not_contains' | null;
|
||||
client_ids?: string[] | null;
|
||||
billable?: boolean | null;
|
||||
time_entry_type?: 'work' | 'break' | null;
|
||||
};
|
||||
}
|
||||
) {
|
||||
const response = await ctx.request.post(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/goals`,
|
||||
{
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type ?? 'personal',
|
||||
comparison: data.comparison ?? 'at_least',
|
||||
target_seconds: data.target_seconds,
|
||||
period: data.period ?? 'week',
|
||||
...('member_id' in data ? { member_id: data.member_id } : {}),
|
||||
...(data.timezone ? { timezone: data.timezone } : {}),
|
||||
...(data.week_start ? { week_start: data.week_start } : {}),
|
||||
filters: data.filters ?? { time_entry_type: 'work' },
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(response.status()).toBe(201);
|
||||
const body = await response.json();
|
||||
return body.data as {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'personal' | 'organization';
|
||||
comparison: 'at_least' | 'less_than';
|
||||
target_seconds: number;
|
||||
period: 'day' | 'week' | 'month';
|
||||
member_id: string | null;
|
||||
member_name: string | null;
|
||||
timezone: string;
|
||||
week_start: string;
|
||||
is_archived: boolean;
|
||||
progress: {
|
||||
tracked_seconds: number;
|
||||
status: 'in_progress' | 'achieved' | 'on_track' | 'exceeded';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateGoalViaApi(
|
||||
ctx: TestContext,
|
||||
goalId: string,
|
||||
data: Record<string, unknown>
|
||||
) {
|
||||
return ctx.request.put(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/goals/${goalId}`,
|
||||
{ data }
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Entity deletion
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
async function deleteEntityViaApi(ctx: TestContext, path: string) {
|
||||
const response = await ctx.request.delete(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/${path}`
|
||||
);
|
||||
expect(response.status()).toBe(204);
|
||||
}
|
||||
|
||||
export async function deleteProjectViaApi(ctx: TestContext, projectId: string) {
|
||||
await deleteEntityViaApi(ctx, `projects/${projectId}`);
|
||||
}
|
||||
|
||||
export async function deleteTaskViaApi(ctx: TestContext, taskId: string) {
|
||||
await deleteEntityViaApi(ctx, `tasks/${taskId}`);
|
||||
}
|
||||
|
||||
export async function deleteTagViaApi(ctx: TestContext, tagId: string) {
|
||||
await deleteEntityViaApi(ctx, `tags/${tagId}`);
|
||||
}
|
||||
|
||||
export async function deleteClientViaApi(ctx: TestContext, clientId: string) {
|
||||
await deleteEntityViaApi(ctx, `clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function deleteMemberViaApi(ctx: TestContext, memberId: string) {
|
||||
await deleteEntityViaApi(ctx, `members/${memberId}`);
|
||||
}
|
||||
|
||||
export async function makeMemberPlaceholderViaApi(ctx: TestContext, memberId: string) {
|
||||
const response = await ctx.request.post(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members/${memberId}/make-placeholder`
|
||||
);
|
||||
expect(response.status()).toBe(204);
|
||||
}
|
||||
|
||||
34
e2e/utils/userSettingsFields.ts
Normal file
34
e2e/utils/userSettingsFields.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Helpers for the shared TimezoneCombobox and WeekStartSelect components used in
|
||||
* the profile form and the goal form.
|
||||
*/
|
||||
|
||||
export function timezoneField(scope: Page | Locator) {
|
||||
// The trigger is named "Timezone: <current value>"
|
||||
return scope.getByRole('button', { name: /^Timezone/ });
|
||||
}
|
||||
|
||||
export function weekStartField(scope: Page | Locator) {
|
||||
return scope.getByRole('combobox', { name: 'Start of the week' });
|
||||
}
|
||||
|
||||
export async function selectTimezone(
|
||||
page: Page,
|
||||
scope: Page | Locator,
|
||||
timezone: string,
|
||||
search: string = timezone
|
||||
) {
|
||||
await timezoneField(scope).click();
|
||||
await page.getByRole('combobox', { name: 'Search timezones' }).fill(search);
|
||||
await page.getByRole('option', { name: timezone, exact: true }).click();
|
||||
await expect(timezoneField(scope)).toHaveText(timezone);
|
||||
}
|
||||
|
||||
export async function selectWeekStart(page: Page, scope: Page | Locator, weekdayLabel: string) {
|
||||
await weekStartField(scope).click();
|
||||
await page.getByRole('option', { name: weekdayLabel, exact: true }).click();
|
||||
await expect(weekStartField(scope)).toHaveText(weekdayLabel);
|
||||
}
|
||||
Reference in New Issue
Block a user