mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
add context menus to calendar view + ui package
This commit is contained in:
@@ -13,6 +13,13 @@ async function goToCalendar(page: Page) {
|
|||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
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
|
* 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.
|
* 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();
|
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
|
// Employee Permission Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
type CreateClientBody,
|
type CreateClientBody,
|
||||||
type CreateProjectBody,
|
type CreateProjectBody,
|
||||||
type Project,
|
type Project,
|
||||||
|
type TimeEntry,
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
|
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||||
import { TimeEntryCalendar } from '@/packages/ui/src';
|
import { TimeEntryCalendar } from '@/packages/ui/src';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { useTagsStore } from '@/utils/useTags';
|
import { useTagsStore } from '@/utils/useTags';
|
||||||
@@ -55,6 +57,39 @@ async function deleteTimeEntry(timeEntryId: string): Promise<void> {
|
|||||||
await deleteTimeEntryMutation(timeEntryId);
|
await deleteTimeEntryMutation(timeEntryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function duplicateTimeEntry(entry: TimeEntry): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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) {
|
async function createTag(name: string) {
|
||||||
return await useTagsStore().createTag(name);
|
return await useTagsStore().createTag(name);
|
||||||
}
|
}
|
||||||
@@ -101,6 +136,8 @@ function onRefresh() {
|
|||||||
:create-time-entry="createTimeEntry"
|
:create-time-entry="createTimeEntry"
|
||||||
:update-time-entry="updateTimeEntry"
|
:update-time-entry="updateTimeEntry"
|
||||||
:delete-time-entry="deleteTimeEntry"
|
:delete-time-entry="deleteTimeEntry"
|
||||||
|
:duplicate-time-entry="duplicateTimeEntry"
|
||||||
|
:split-time-entry="splitTimeEntry"
|
||||||
:create-client="createClient"
|
:create-client="createClient"
|
||||||
:create-project="createProject"
|
:create-project="createProject"
|
||||||
:create-tag="createTag"
|
:create-tag="createTag"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
onActivated,
|
onActivated,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
import type { EventApi } from '@fullcalendar/core';
|
||||||
import { useLocalStorage } from '@vueuse/core';
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
import chroma from 'chroma-js';
|
import chroma from 'chroma-js';
|
||||||
import { useCssVariable } from '@/utils/useCssVariable';
|
import { useCssVariable } from '@/utils/useCssVariable';
|
||||||
@@ -31,6 +32,20 @@ import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
|||||||
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
|
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
|
||||||
import type { CalendarSettings } from './calendarSettings';
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
import { useVisualSnap } from './useVisualSnap';
|
import { useVisualSnap } from './useVisualSnap';
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from '..';
|
||||||
|
import {
|
||||||
|
PencilIcon,
|
||||||
|
DocumentDuplicateIcon,
|
||||||
|
TrashIcon,
|
||||||
|
ScissorsIcon,
|
||||||
|
PlusIcon,
|
||||||
|
} from '@heroicons/vue/20/solid';
|
||||||
import activityStatusPlugin, {
|
import activityStatusPlugin, {
|
||||||
type ActivityPeriod,
|
type ActivityPeriod,
|
||||||
renderActivityStatusBoxes,
|
renderActivityStatusBoxes,
|
||||||
@@ -79,6 +94,8 @@ const props = defineProps<{
|
|||||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
createTag: (name: string) => Promise<Tag | undefined>;
|
createTag: (name: string) => Promise<Tag | undefined>;
|
||||||
|
duplicateTimeEntry: (entry: TimeEntry) => Promise<void>;
|
||||||
|
splitTimeEntry: (entry: TimeEntry) => Promise<void>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Local component state
|
// Local component state
|
||||||
@@ -255,6 +272,100 @@ function handleEventClick(arg: EventClickArg) {
|
|||||||
showEditTimeEntryModal.value = true;
|
showEditTimeEntryModal.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Context menu state
|
||||||
|
const contextMenuTimeEntry = ref<TimeEntry | null>(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<HTMLElement>('[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)
|
// Snap a dayjs time down to the previous snap boundary (for start times)
|
||||||
function snapStartToGrid(time: Dayjs, snapMinutes: number): Dayjs {
|
function snapStartToGrid(time: Dayjs, snapMinutes: number): Dayjs {
|
||||||
const minutes = time.hour() * 60 + time.minute();
|
const minutes = time.hour() * 60 + time.minute();
|
||||||
@@ -387,6 +498,9 @@ const calendarOptions = computed(() => {
|
|||||||
eventResizeStart: startVisualResizeSnap,
|
eventResizeStart: startVisualResizeSnap,
|
||||||
eventResize: handleEventResize,
|
eventResize: handleEventResize,
|
||||||
datesSet: emitDatesChange,
|
datesSet: emitDatesChange,
|
||||||
|
eventDidMount: (arg: { el: HTMLElement; event: { id: string } }) => {
|
||||||
|
arg.el.setAttribute('data-event-id', arg.event.id);
|
||||||
|
},
|
||||||
|
|
||||||
events: events.value,
|
events: events.value,
|
||||||
activityPeriods: props.activityPeriods || [],
|
activityPeriods: props.activityPeriods || [],
|
||||||
@@ -509,36 +623,71 @@ onUnmounted(() => {
|
|||||||
:settings="calendarSettings"
|
:settings="calendarSettings"
|
||||||
@update:settings="onSettingsUpdate" />
|
@update:settings="onSettingsUpdate" />
|
||||||
</div>
|
</div>
|
||||||
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
|
<ContextMenu>
|
||||||
<template #eventContent="arg">
|
<ContextMenuTrigger as="div" class="h-full" @contextmenu="handleCalendarContextMenu">
|
||||||
<FullCalendarEventContent
|
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
|
||||||
:title="arg.event.title"
|
<template #eventContent="arg">
|
||||||
:project-name="(arg.event.extendedProps as any).project?.name"
|
<FullCalendarEventContent
|
||||||
:task-name="(arg.event.extendedProps as any).task?.name"
|
:title="arg.event.title"
|
||||||
:client-name="(arg.event.extendedProps as any).client?.name"
|
:project-name="(arg.event.extendedProps as any).project?.name"
|
||||||
:duration-seconds="
|
:task-name="(arg.event.extendedProps as any).task?.name"
|
||||||
((arg.event.extendedProps as any).duration ?? undefined)
|
:client-name="(arg.event.extendedProps as any).client?.name"
|
||||||
? (arg.event.extendedProps as any).duration * 60
|
:duration-seconds="
|
||||||
: undefined
|
((arg.event.extendedProps as any).duration ?? undefined)
|
||||||
"
|
? (arg.event.extendedProps as any).duration * 60
|
||||||
:start="arg.event.start as any"
|
: undefined
|
||||||
:end="arg.event.end as any" />
|
"
|
||||||
</template>
|
:start="arg.event.start as any"
|
||||||
<template #dayHeaderContent="arg">
|
:end="arg.event.end as any" />
|
||||||
<FullCalendarDayHeader
|
</template>
|
||||||
:date="
|
<template #dayHeaderContent="arg">
|
||||||
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
|
<FullCalendarDayHeader
|
||||||
"
|
:date="
|
||||||
:total-seconds="
|
getDayJsInstance()(arg.date.toISOString())
|
||||||
dailyTotals[
|
.utc()
|
||||||
getDayJsInstance()(arg.date)
|
.tz(getUserTimezone(), true)
|
||||||
.utc()
|
"
|
||||||
.tz(getUserTimezone(), true)
|
:total-seconds="
|
||||||
.format('YYYY-MM-DD')
|
dailyTotals[
|
||||||
] || 0
|
getDayJsInstance()(arg.date)
|
||||||
" />
|
.utc()
|
||||||
</template>
|
.tz(getUserTimezone(), true)
|
||||||
</FullCalendar>
|
.format('YYYY-MM-DD')
|
||||||
|
] || 0
|
||||||
|
" />
|
||||||
|
</template>
|
||||||
|
</FullCalendar>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent class="min-w-[160px]">
|
||||||
|
<template v-if="contextMenuTimeEntry">
|
||||||
|
<ContextMenuItem class="space-x-3" @select="handleContextEdit()">
|
||||||
|
<PencilIcon class="w-4 h-4 text-icon-default" />
|
||||||
|
<span>Edit</span>
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem class="space-x-3" @select="handleContextDuplicate()">
|
||||||
|
<DocumentDuplicateIcon class="w-4 h-4 text-icon-default" />
|
||||||
|
<span>Duplicate</span>
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem class="space-x-3" @select="handleContextSplit()">
|
||||||
|
<ScissorsIcon class="w-4 h-4 text-icon-default" />
|
||||||
|
<span>Split</span>
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
class="space-x-3 text-destructive"
|
||||||
|
@select="handleContextDelete()">
|
||||||
|
<TrashIcon class="w-4 h-4 text-icon-default" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</ContextMenuItem>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<ContextMenuItem class="space-x-3" @select="handleContextCreate()">
|
||||||
|
<PlusIcon class="w-4 h-4 text-icon-default" />
|
||||||
|
<span>Create Time Entry</span>
|
||||||
|
</ContextMenuItem>
|
||||||
|
</template>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
15
resources/js/packages/ui/src/context-menu/ContextMenu.vue
Normal file
15
resources/js/packages/ui/src/context-menu/ContextMenu.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextMenuRootEmits, ContextMenuRootProps } from 'reka-ui';
|
||||||
|
import { ContextMenuRoot, useForwardPropsEmits } from 'reka-ui';
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuRootProps>();
|
||||||
|
const emits = defineEmits<ContextMenuRootEmits>();
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuRoot v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</ContextMenuRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from 'reka-ui';
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { reactiveOmit } from '@vueuse/core';
|
||||||
|
import { Check } from 'lucide-vue-next';
|
||||||
|
import { ContextMenuCheckboxItem, ContextMenuItemIndicator, useForwardPropsEmits } from 'reka-ui';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>();
|
||||||
|
const emits = defineEmits<ContextMenuCheckboxItemEmits>();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class');
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuCheckboxItem
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
props.class
|
||||||
|
)
|
||||||
|
">
|
||||||
|
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuItemIndicator>
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</ContextMenuItemIndicator>
|
||||||
|
</span>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuCheckboxItem>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextMenuContentEmits, ContextMenuContentProps } from 'reka-ui';
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { reactiveOmit } from '@vueuse/core';
|
||||||
|
import { ContextMenuContent, ContextMenuPortal, useForwardPropsEmits } from 'reka-ui';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes['class'] }>();
|
||||||
|
const emits = defineEmits<ContextMenuContentEmits>();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class');
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuPortal>
|
||||||
|
<ContextMenuContent
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
props.class
|
||||||
|
)
|
||||||
|
">
|
||||||
|
<slot />
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenuPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextMenuGroupProps } from 'reka-ui';
|
||||||
|
import { ContextMenuGroup } from 'reka-ui';
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuGroupProps>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuGroup v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</ContextMenuGroup>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextMenuItemEmits, ContextMenuItemProps } from 'reka-ui';
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { reactiveOmit } from '@vueuse/core';
|
||||||
|
import { ContextMenuItem, useForwardPropsEmits } from 'reka-ui';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
ContextMenuItemProps & { class?: HTMLAttributes['class']; inset?: boolean }
|
||||||
|
>();
|
||||||
|
const emits = defineEmits<ContextMenuItemEmits>();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class');
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuItem
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
props.class
|
||||||
|
)
|
||||||
|
">
|
||||||
|
<slot />
|
||||||
|
</ContextMenuItem>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextMenuLabelProps } from 'reka-ui';
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { reactiveOmit } from '@vueuse/core';
|
||||||
|
import { ContextMenuLabel } from 'reka-ui';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
ContextMenuLabelProps & { class?: HTMLAttributes['class']; inset?: boolean }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuLabel
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', props.class)
|
||||||
|
">
|
||||||
|
<slot />
|
||||||
|
</ContextMenuLabel>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextMenuPortalProps } from 'reka-ui';
|
||||||
|
import { ContextMenuPortal } from 'reka-ui';
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuPortalProps>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuPortal v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</ContextMenuPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextMenuRadioGroupEmits, ContextMenuRadioGroupProps } from 'reka-ui';
|
||||||
|
import { ContextMenuRadioGroup, useForwardPropsEmits } from 'reka-ui';
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuRadioGroupProps>();
|
||||||
|
const emits = defineEmits<ContextMenuRadioGroupEmits>();
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuRadioGroup v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</ContextMenuRadioGroup>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from 'reka-ui';
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { reactiveOmit } from '@vueuse/core';
|
||||||
|
import { Circle } from 'lucide-vue-next';
|
||||||
|
import { ContextMenuItemIndicator, ContextMenuRadioItem, useForwardPropsEmits } from 'reka-ui';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes['class'] }>();
|
||||||
|
const emits = defineEmits<ContextMenuRadioItemEmits>();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class');
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuRadioItem
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
props.class
|
||||||
|
)
|
||||||
|
">
|
||||||
|
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuItemIndicator>
|
||||||
|
<Circle class="h-4 w-4 fill-current" />
|
||||||
|
</ContextMenuItemIndicator>
|
||||||
|
</span>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuRadioItem>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextMenuSeparatorProps } from 'reka-ui';
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { reactiveOmit } from '@vueuse/core';
|
||||||
|
import { ContextMenuSeparator } from 'reka-ui';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuSeparatorProps & { class?: HTMLAttributes['class'] }>();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuSeparator
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('-mx-1 my-1 h-px bg-border', props.class)" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class'];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
15
resources/js/packages/ui/src/context-menu/ContextMenuSub.vue
Normal file
15
resources/js/packages/ui/src/context-menu/ContextMenuSub.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextMenuSubEmits, ContextMenuSubProps } from 'reka-ui';
|
||||||
|
import { ContextMenuSub, useForwardPropsEmits } from 'reka-ui';
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuSubProps>();
|
||||||
|
const emits = defineEmits<ContextMenuSubEmits>();
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuSub v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</ContextMenuSub>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui';
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { reactiveOmit } from '@vueuse/core';
|
||||||
|
import { ContextMenuSubContent, useForwardPropsEmits } from 'reka-ui';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>();
|
||||||
|
const emits = defineEmits<DropdownMenuSubContentEmits>();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class');
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuSubContent
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
props.class
|
||||||
|
)
|
||||||
|
">
|
||||||
|
<slot />
|
||||||
|
</ContextMenuSubContent>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextMenuSubTriggerProps } from 'reka-ui';
|
||||||
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { reactiveOmit } from '@vueuse/core';
|
||||||
|
import { ChevronRight } from 'lucide-vue-next';
|
||||||
|
import { ContextMenuSubTrigger, useForwardProps } from 'reka-ui';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const props = defineProps<
|
||||||
|
ContextMenuSubTriggerProps & { class?: HTMLAttributes['class']; inset?: boolean }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class');
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuSubTrigger
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||||
|
inset && 'pl-8',
|
||||||
|
props.class
|
||||||
|
)
|
||||||
|
">
|
||||||
|
<slot />
|
||||||
|
<ChevronRight class="ml-auto h-4 w-4" />
|
||||||
|
</ContextMenuSubTrigger>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextMenuTriggerProps } from 'reka-ui';
|
||||||
|
import { ContextMenuTrigger, useForwardProps } from 'reka-ui';
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuTriggerProps>();
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(props);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuTrigger v-bind="forwardedProps">
|
||||||
|
<slot />
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
</template>
|
||||||
14
resources/js/packages/ui/src/context-menu/index.ts
Normal file
14
resources/js/packages/ui/src/context-menu/index.ts
Normal file
@@ -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';
|
||||||
@@ -45,6 +45,22 @@ import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './popove
|
|||||||
import { RangeCalendar } from './range-calendar/index';
|
import { RangeCalendar } from './range-calendar/index';
|
||||||
import { CommandPalette } from './CommandPalette/index';
|
import { CommandPalette } from './CommandPalette/index';
|
||||||
import { Separator } from './separator/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 {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldContent,
|
FieldContent,
|
||||||
@@ -123,4 +139,18 @@ export {
|
|||||||
FieldSet,
|
FieldSet,
|
||||||
FieldTitle,
|
FieldTitle,
|
||||||
fieldVariants,
|
fieldVariants,
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuTrigger,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user