diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index ec2b5b2b..f50ba685 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -13,6 +13,13 @@ async function goToCalendar(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); } +async function openContextMenu(page: Page, description: string) { + const event = page.locator('.fc-event').filter({ hasText: description }).first(); + await expect(event).toBeVisible(); + await event.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); +} + /** * These tests verify that changing the project on a time entry via the calendar * updates the billable status to match the new project's is_billable setting. @@ -290,6 +297,124 @@ test('test that deleting time entry from calendar modal works', async ({ page, c await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible(); }); +// ============================================= +// Context Menu Tests +// ============================================= + +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 goToCalendar(page); + await openContextMenu(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 preserves project and billable status', 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 goToCalendar(page); + await expect(page.locator('.fc-event').filter({ hasText: description })).toHaveCount(1); + await openContextMenu(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); + await expect(page.locator('.fc-event').filter({ hasText: description })).toHaveCount(2); +}); + +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 goToCalendar(page); + await openContextMenu(page, description); + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + page.getByRole('menuitem', { name: 'Delete' }).click(), + ]); + + await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible(); +}); + +test('test that context menu split divides time entry into two', async ({ page, ctx }) => { + const description = 'Context split test ' + Math.floor(1 + Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, description, '2h'); + + await goToCalendar(page); + await expect(page.locator('.fc-event').filter({ hasText: description })).toHaveCount(1); + await openContextMenu(page, description); + + const [updateResponse, createResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'POST' && + response.status() === 201 + ), + page.getByRole('menuitem', { name: 'Split' }).click(), + ]); + + const updateBody = await updateResponse.json(); + const createBody = await createResponse.json(); + expect(updateBody.data.end).toBe(createBody.data.start); + await expect(page.locator('.fc-event').filter({ hasText: description })).toHaveCount(2); +}); + +test('test that context menu create time entry opens the create modal', async ({ page }) => { + await goToCalendar(page); + await expect(page.locator('.fc')).toBeVisible(); + + const slotLane = page.locator('.fc-timegrid-slot-lane').first(); + await expect(slotLane).toBeVisible(); + await slotLane.click({ button: 'right' }); + + await expect(page.getByRole('menu')).toBeVisible(); + await page.getByRole('menuitem', { name: 'Create Time Entry' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); +}); + // ============================================= // Employee Permission Tests // ============================================= diff --git a/resources/js/Pages/Calendar.vue b/resources/js/Pages/Calendar.vue index f87c6a37..e752fe0c 100644 --- a/resources/js/Pages/Calendar.vue +++ b/resources/js/Pages/Calendar.vue @@ -9,7 +9,9 @@ import { type CreateClientBody, type CreateProjectBody, type Project, + type TimeEntry, } from '@/packages/api/src'; +import { getDayJsInstance } from '@/packages/ui/src/utils/time'; import { TimeEntryCalendar } from '@/packages/ui/src'; import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { useTagsStore } from '@/utils/useTags'; @@ -55,6 +57,39 @@ async function deleteTimeEntry(timeEntryId: string): Promise { await deleteTimeEntryMutation(timeEntryId); } +async function duplicateTimeEntry(entry: TimeEntry): Promise { + await createTimeEntryMutation({ + start: entry.start, + end: entry.end, + billable: entry.billable, + description: entry.description, + project_id: entry.project_id, + task_id: entry.task_id, + tags: entry.tags, + }); +} + +async function splitTimeEntry(entry: TimeEntry): Promise { + if (!entry.end) return; + const start = getDayJsInstance()(entry.start); + const end = getDayJsInstance()(entry.end); + const midpoint = start.add(end.diff(start) / 2, 'millisecond').startOf('minute'); + + // Update the original entry to end at the midpoint + await updateTimeEntryMutation({ ...entry, end: midpoint.utc().format() }); + + // Create a new entry from midpoint to original end + await createTimeEntryMutation({ + start: midpoint.utc().format(), + end: entry.end, + billable: entry.billable, + description: entry.description, + project_id: entry.project_id, + task_id: entry.task_id, + tags: entry.tags, + }); +} + async function createTag(name: string) { return await useTagsStore().createTag(name); } @@ -101,6 +136,8 @@ function onRefresh() { :create-time-entry="createTimeEntry" :update-time-entry="updateTimeEntry" :delete-time-entry="deleteTimeEntry" + :duplicate-time-entry="duplicateTimeEntry" + :split-time-entry="splitTimeEntry" :create-client="createClient" :create-project="createProject" :create-tag="createTag" diff --git a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue index 133f6ff7..b3086277 100644 --- a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue +++ b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue @@ -15,6 +15,7 @@ import { onActivated, onUnmounted, } from 'vue'; +import type { EventApi } from '@fullcalendar/core'; import { useLocalStorage } from '@vueuse/core'; import chroma from 'chroma-js'; import { useCssVariable } from '@/utils/useCssVariable'; @@ -31,6 +32,20 @@ import FullCalendarDayHeader from './FullCalendarDayHeader.vue'; import CalendarSettingsPopover from './CalendarSettingsPopover.vue'; import type { CalendarSettings } from './calendarSettings'; import { useVisualSnap } from './useVisualSnap'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '..'; +import { + PencilIcon, + DocumentDuplicateIcon, + TrashIcon, + ScissorsIcon, + PlusIcon, +} from '@heroicons/vue/20/solid'; import activityStatusPlugin, { type ActivityPeriod, renderActivityStatusBoxes, @@ -79,6 +94,8 @@ const props = defineProps<{ createProject: (project: CreateProjectBody) => Promise; createClient: (client: CreateClientBody) => Promise; createTag: (name: string) => Promise; + duplicateTimeEntry: (entry: TimeEntry) => Promise; + splitTimeEntry: (entry: TimeEntry) => Promise; }>(); // Local component state @@ -255,6 +272,100 @@ function handleEventClick(arg: EventClickArg) { showEditTimeEntryModal.value = true; } +// Context menu state +const contextMenuTimeEntry = ref(null); +const contextMenuCreateTime = ref<{ start: Dayjs; end: Dayjs } | null>(null); + +function getTimeAtClickPosition(event: MouseEvent): { start: Dayjs; end: Dayjs } | null { + // FullCalendar's time grid has two overlapping tables: slots (data-time) and + // cols (data-date). The cols layer often has pointer-events: none, so clicks + // land on the slots table. Use elementsFromPoint to reach both layers. + const elements = document.elementsFromPoint(event.clientX, event.clientY); + let time: string | null = null; + let date: string | null = null; + + for (const el of elements) { + if (el instanceof HTMLElement) { + if (!time && el.dataset.time !== undefined) { + time = el.dataset.time; + } + if (!date && el.dataset.date !== undefined) { + date = el.dataset.date; + } + } + if (time && date) break; + } + + if (!time || !date) return null; + + const snap = calendarSettings.value.snapMinutes; + const startLocal = getDayJsInstance()(`${date}T${time}`).tz(getUserTimezone(), true); + const snappedStart = snapStartToGrid(startLocal, snap); + const snappedEnd = snappedStart.add(snap, 'minute'); + + return { start: snappedStart.utc(), end: snappedEnd.utc() }; +} + +function handleCalendarContextMenu(event: MouseEvent) { + const target = event.target as HTMLElement; + const eventEl = target.closest('[data-event-id]'); + + if (!eventEl) { + // Right-click on empty calendar space — show "Create Time Entry" + contextMenuTimeEntry.value = null; + const timeInfo = getTimeAtClickPosition(event); + contextMenuCreateTime.value = timeInfo; + return; + } + + const eventId = eventEl.getAttribute('data-event-id'); + if (!eventId) return; + + const api = calendarRef.value?.getApi(); + if (!api) return; + + const fcEvent: EventApi | undefined = api.getEvents().find((e) => e.id === eventId); + if (!fcEvent) return; + + const ext = fcEvent.extendedProps as CalendarExtendedProps; + if (ext.isRunning) return; + + contextMenuTimeEntry.value = ext.timeEntry; + contextMenuCreateTime.value = null; +} + +function handleContextEdit() { + if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return; + selectedTimeEntry.value = contextMenuTimeEntry.value; + showEditTimeEntryModal.value = true; +} + +async function handleContextDuplicate() { + if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return; + await props.duplicateTimeEntry(contextMenuTimeEntry.value); + emit('refresh'); +} + +async function handleContextDelete() { + if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return; + await props.deleteTimeEntry(contextMenuTimeEntry.value.id); + emit('refresh'); +} + +async function handleContextSplit() { + if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return; + await props.splitTimeEntry(contextMenuTimeEntry.value); + emit('refresh'); +} + +function handleContextCreate() { + if (contextMenuCreateTime.value) { + newEventStart.value = contextMenuCreateTime.value.start; + newEventEnd.value = contextMenuCreateTime.value.end; + } + showCreateTimeEntryModal.value = true; +} + // Snap a dayjs time down to the previous snap boundary (for start times) function snapStartToGrid(time: Dayjs, snapMinutes: number): Dayjs { const minutes = time.hour() * 60 + time.minute(); @@ -387,6 +498,9 @@ const calendarOptions = computed(() => { eventResizeStart: startVisualResizeSnap, eventResize: handleEventResize, datesSet: emitDatesChange, + eventDidMount: (arg: { el: HTMLElement; event: { id: string } }) => { + arg.el.setAttribute('data-event-id', arg.event.id); + }, events: events.value, activityPeriods: props.activityPeriods || [], @@ -509,36 +623,71 @@ onUnmounted(() => { :settings="calendarSettings" @update:settings="onSettingsUpdate" /> - - - - + + + + + + + + + + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenu.vue b/resources/js/packages/ui/src/context-menu/ContextMenu.vue new file mode 100644 index 00000000..2814ac96 --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenu.vue @@ -0,0 +1,15 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenuCheckboxItem.vue b/resources/js/packages/ui/src/context-menu/ContextMenuCheckboxItem.vue new file mode 100644 index 00000000..69e72863 --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenuCheckboxItem.vue @@ -0,0 +1,33 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenuContent.vue b/resources/js/packages/ui/src/context-menu/ContextMenuContent.vue new file mode 100644 index 00000000..0c2310cb --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenuContent.vue @@ -0,0 +1,29 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenuGroup.vue b/resources/js/packages/ui/src/context-menu/ContextMenuGroup.vue new file mode 100644 index 00000000..16c28c55 --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenuGroup.vue @@ -0,0 +1,12 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenuItem.vue b/resources/js/packages/ui/src/context-menu/ContextMenuItem.vue new file mode 100644 index 00000000..bc008368 --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenuItem.vue @@ -0,0 +1,30 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenuLabel.vue b/resources/js/packages/ui/src/context-menu/ContextMenuLabel.vue new file mode 100644 index 00000000..9113b5cc --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenuLabel.vue @@ -0,0 +1,23 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenuPortal.vue b/resources/js/packages/ui/src/context-menu/ContextMenuPortal.vue new file mode 100644 index 00000000..f98b24d8 --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenuPortal.vue @@ -0,0 +1,12 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenuRadioGroup.vue b/resources/js/packages/ui/src/context-menu/ContextMenuRadioGroup.vue new file mode 100644 index 00000000..e7ddad60 --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenuRadioGroup.vue @@ -0,0 +1,15 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenuRadioItem.vue b/resources/js/packages/ui/src/context-menu/ContextMenuRadioItem.vue new file mode 100644 index 00000000..2e9f0e55 --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenuRadioItem.vue @@ -0,0 +1,33 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenuSeparator.vue b/resources/js/packages/ui/src/context-menu/ContextMenuSeparator.vue new file mode 100644 index 00000000..aaf385c6 --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenuSeparator.vue @@ -0,0 +1,17 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenuShortcut.vue b/resources/js/packages/ui/src/context-menu/ContextMenuShortcut.vue new file mode 100644 index 00000000..22697fac --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenuShortcut.vue @@ -0,0 +1,14 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenuSub.vue b/resources/js/packages/ui/src/context-menu/ContextMenuSub.vue new file mode 100644 index 00000000..61792a0e --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenuSub.vue @@ -0,0 +1,15 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenuSubContent.vue b/resources/js/packages/ui/src/context-menu/ContextMenuSubContent.vue new file mode 100644 index 00000000..2eb238db --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenuSubContent.vue @@ -0,0 +1,27 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenuSubTrigger.vue b/resources/js/packages/ui/src/context-menu/ContextMenuSubTrigger.vue new file mode 100644 index 00000000..7996b275 --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenuSubTrigger.vue @@ -0,0 +1,31 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/ContextMenuTrigger.vue b/resources/js/packages/ui/src/context-menu/ContextMenuTrigger.vue new file mode 100644 index 00000000..90d2b869 --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/ContextMenuTrigger.vue @@ -0,0 +1,14 @@ + + + diff --git a/resources/js/packages/ui/src/context-menu/index.ts b/resources/js/packages/ui/src/context-menu/index.ts new file mode 100644 index 00000000..c9f7d0da --- /dev/null +++ b/resources/js/packages/ui/src/context-menu/index.ts @@ -0,0 +1,14 @@ +export { default as ContextMenu } from './ContextMenu.vue'; +export { default as ContextMenuCheckboxItem } from './ContextMenuCheckboxItem.vue'; +export { default as ContextMenuContent } from './ContextMenuContent.vue'; +export { default as ContextMenuGroup } from './ContextMenuGroup.vue'; +export { default as ContextMenuItem } from './ContextMenuItem.vue'; +export { default as ContextMenuLabel } from './ContextMenuLabel.vue'; +export { default as ContextMenuRadioGroup } from './ContextMenuRadioGroup.vue'; +export { default as ContextMenuRadioItem } from './ContextMenuRadioItem.vue'; +export { default as ContextMenuSeparator } from './ContextMenuSeparator.vue'; +export { default as ContextMenuShortcut } from './ContextMenuShortcut.vue'; +export { default as ContextMenuSub } from './ContextMenuSub.vue'; +export { default as ContextMenuSubContent } from './ContextMenuSubContent.vue'; +export { default as ContextMenuSubTrigger } from './ContextMenuSubTrigger.vue'; +export { default as ContextMenuTrigger } from './ContextMenuTrigger.vue'; diff --git a/resources/js/packages/ui/src/index.ts b/resources/js/packages/ui/src/index.ts index ed6712cc..cedc4820 100644 --- a/resources/js/packages/ui/src/index.ts +++ b/resources/js/packages/ui/src/index.ts @@ -45,6 +45,22 @@ import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './popove import { RangeCalendar } from './range-calendar/index'; import { CommandPalette } from './CommandPalette/index'; import { Separator } from './separator/index'; +import { + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuLabel, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from './context-menu/index'; import { Field, FieldContent, @@ -123,4 +139,18 @@ export { FieldSet, FieldTitle, fieldVariants, + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuLabel, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, };