diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts index 93abb9f8..9c311b03 100644 --- a/e2e/time.spec.ts +++ b/e2e/time.spec.ts @@ -2042,3 +2042,228 @@ test.describe('Employee Time Entry Isolation', () => { await expect(timeEntryRow).not.toBeVisible(); }); }); + +// ============================================= +// Context Menu Tests +// ============================================= + +async function openTimeEntryContextMenu(page: Page, description: string) { + const row = page + .locator('[data-testid="time_entry_row"]') + .filter({ hasText: description }) + .first(); + await row.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); +} + +test('test that context menu appears with correct items on time entry row', async ({ + page, + ctx, +}) => { + const description = 'Context menu items test ' + Math.floor(1 + Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, description, '1h'); + + await goToTimeOverview(page); + await openTimeEntryContextMenu(page, description); + + await expect(page.getByRole('menuitem', { name: 'Continue' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible(); +}); + +test('test that context menu edit opens the edit modal', async ({ page, ctx }) => { + const description = 'Context edit test ' + Math.floor(1 + Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, description, '1h'); + + await goToTimeOverview(page); + await openTimeEntryContextMenu(page, description); + + await page.getByRole('menuitem', { name: 'Edit' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('dialog').getByPlaceholder('What did you work on?')).toHaveValue( + description + ); +}); + +test('test that context menu duplicate creates a copy', async ({ page, ctx }) => { + const description = 'Context dup test ' + Math.floor(1 + Math.random() * 10000); + const project = await createProjectViaApi(ctx, { + name: 'Dup Project ' + Math.floor(1 + Math.random() * 10000), + is_billable: true, + }); + await createTimeEntryViaApi(ctx, { + description, + duration: '1h', + projectId: project.id, + billable: true, + }); + + await goToTimeOverview(page); + await openTimeEntryContextMenu(page, description); + + const [createResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'POST' && + response.status() === 201 + ), + page.getByRole('menuitem', { name: 'Duplicate' }).click(), + ]); + + const body = await createResponse.json(); + expect(body.data.description).toBe(description); + expect(body.data.project_id).toBe(project.id); + expect(body.data.billable).toBe(true); +}); + +test('test that context menu continue starts a new time entry', async ({ page, ctx }) => { + const description = 'Context continue test ' + Math.floor(1 + Math.random() * 10000); + const project = await createProjectViaApi(ctx, { + name: 'Continue Project ' + Math.floor(1 + Math.random() * 10000), + is_billable: false, + }); + await createTimeEntryViaApi(ctx, { + description, + duration: '1h', + projectId: project.id, + }); + + await goToTimeOverview(page); + await openTimeEntryContextMenu(page, description); + + const [createResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'POST' && + response.status() === 201 + ), + page.getByRole('menuitem', { name: 'Continue' }).click(), + ]); + + const body = await createResponse.json(); + expect(body.data.description).toBe(description); + expect(body.data.project_id).toBe(project.id); + expect(body.data.end).toBeNull(); +}); + +test('test that context menu delete removes the time entry', async ({ page, ctx }) => { + const description = 'Context delete test ' + Math.floor(1 + Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, description, '1h'); + + await goToTimeOverview(page); + await openTimeEntryContextMenu(page, description); + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && response.request().method() === 'DELETE' + ), + page.getByRole('menuitem', { name: 'Delete' }).click(), + ]); + + await expect( + page.locator('[data-testid="time_entry_row"]').filter({ hasText: description }) + ).not.toBeVisible(); +}); + +test('test that aggregate row context menu shows only Continue and Delete', async ({ + page, + ctx, +}) => { + const description = 'Context agg items ' + Math.floor(1 + Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, description, '1h'); + await createBareTimeEntryViaApi(ctx, description, '30min'); + + await goToTimeOverview(page); + + const aggregateRow = page + .locator('[data-testid="time_entry_row"]') + .filter({ hasText: description }) + .first(); + await aggregateRow.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + + await expect(page.getByRole('menuitem', { name: 'Continue' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Edit' })).not.toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Duplicate' })).not.toBeVisible(); +}); + +test('test that aggregate row context menu continue starts a new time entry', async ({ + page, + ctx, +}) => { + const description = 'Context agg continue ' + Math.floor(1 + Math.random() * 10000); + const project = await createProjectViaApi(ctx, { + name: 'Agg Continue Project ' + Math.floor(1 + Math.random() * 10000), + is_billable: false, + }); + await createTimeEntryViaApi(ctx, { + description, + duration: '1h', + projectId: project.id, + }); + await createTimeEntryViaApi(ctx, { + description, + duration: '30min', + projectId: project.id, + }); + + await goToTimeOverview(page); + + const aggregateRow = page + .locator('[data-testid="time_entry_row"]') + .filter({ hasText: description }) + .first(); + await aggregateRow.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + + const [createResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'POST' && + response.status() === 201 + ), + page.getByRole('menuitem', { name: 'Continue' }).click(), + ]); + + const body = await createResponse.json(); + expect(body.data.description).toBe(description); + expect(body.data.project_id).toBe(project.id); + expect(body.data.end).toBeNull(); +}); + +test('test that aggregate row context menu delete removes all grouped entries', async ({ + page, + ctx, +}) => { + const description = 'Context agg delete ' + Math.floor(1 + Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, description, '1h'); + await createBareTimeEntryViaApi(ctx, description, '30min'); + + await goToTimeOverview(page); + + // The aggregate row groups entries with same description + const aggregateRow = page + .locator('[data-testid="time_entry_row"]') + .filter({ hasText: description }) + .first(); + await aggregateRow.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && response.request().method() === 'DELETE' + ), + page.getByRole('menuitem', { name: 'Delete' }).click(), + ]); + + await expect( + page.locator('[data-testid="time_entry_row"]').filter({ hasText: description }) + ).not.toBeVisible(); +}); diff --git a/resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue b/resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue index fb4ce1bc..2408a364 100644 --- a/resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue +++ b/resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue @@ -21,7 +21,15 @@ import { formatHumanReadableDuration, formatStartEnd } from '@/packages/ui/src/u import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue'; import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue'; import type { TimeEntriesGroupedByType } from '@/types/time-entries'; -import { Checkbox } from '@/packages/ui/src'; +import { + Checkbox, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/packages/ui/src'; +import { PlayIcon, TrashIcon } from '@heroicons/vue/20/solid'; import { twMerge } from 'tailwind-merge'; const props = defineProps<{ timeEntry: TimeEntriesGroupedByType; @@ -89,156 +97,99 @@ function onSelectChange(checked: boolean) { diff --git a/resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue b/resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue index 36f3e974..c72d5d1d 100644 --- a/resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue +++ b/resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue @@ -20,7 +20,15 @@ import { TimeEntryEditModal } from '@/packages/ui/src'; import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue'; import { computed, ref } from 'vue'; import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue'; -import { Checkbox } from '@/packages/ui/src'; +import { + Checkbox, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/packages/ui/src'; +import { PlayIcon, PencilIcon, DocumentDuplicateIcon, TrashIcon } from '@heroicons/vue/20/solid'; const props = defineProps<{ timeEntry: TimeEntry; @@ -109,123 +117,150 @@ async function handleDeleteTimeEntry() {