mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 12:42:15 +01:00
Add context menu actions for running entries in calendar
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
|||||||
createProjectViaApi,
|
createProjectViaApi,
|
||||||
createBareTimeEntryViaApi,
|
createBareTimeEntryViaApi,
|
||||||
createTimeEntryViaApi,
|
createTimeEntryViaApi,
|
||||||
|
createRunningTimeEntryViaApi,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
|
|
||||||
async function goToCalendar(page: Page) {
|
async function goToCalendar(page: Page) {
|
||||||
@@ -415,6 +416,66 @@ test('test that context menu create time entry opens the create modal', async ({
|
|||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that context menu for running entry shows stop and discard options', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const description = 'Running ctx menu test ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createRunningTimeEntryViaApi(ctx, description);
|
||||||
|
|
||||||
|
await goToCalendar(page);
|
||||||
|
await openContextMenu(page, description);
|
||||||
|
|
||||||
|
// Running entry should show Stop and Discard, not Edit/Duplicate/Split/Delete
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Stop' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Discard' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Edit' })).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Split' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that context menu stop on running entry sets end time', async ({ page, ctx }) => {
|
||||||
|
const description = 'Running stop test ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createRunningTimeEntryViaApi(ctx, description);
|
||||||
|
|
||||||
|
await goToCalendar(page);
|
||||||
|
await openContextMenu(page, description);
|
||||||
|
|
||||||
|
const [updateResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/time-entries/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem', { name: 'Stop' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const body = await updateResponse.json();
|
||||||
|
expect(body.data.end).not.toBeNull();
|
||||||
|
expect(body.data.description).toBe(description);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that context menu discard on running entry deletes it', async ({ page, ctx }) => {
|
||||||
|
const description = 'Running discard test ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createRunningTimeEntryViaApi(ctx, description);
|
||||||
|
|
||||||
|
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: 'Discard' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Employee Permission Tests
|
// Employee Permission Tests
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -473,6 +473,25 @@ export async function createTimeEntryWithTagViaApi(
|
|||||||
return { tag, entry };
|
return { tag, entry };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createRunningTimeEntryViaApi(ctx: TestContext, description: string) {
|
||||||
|
const start = new Date();
|
||||||
|
start.setMinutes(start.getMinutes() - 10);
|
||||||
|
const response = await ctx.request.post(
|
||||||
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
member_id: ctx.memberId,
|
||||||
|
start: formatTimestamp(start),
|
||||||
|
description,
|
||||||
|
billable: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(201);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.data as { id: string; start: string; end: null; description: string };
|
||||||
|
}
|
||||||
|
|
||||||
export async function createBareTimeEntryViaApi(
|
export async function createBareTimeEntryViaApi(
|
||||||
ctx: TestContext,
|
ctx: TestContext,
|
||||||
description: string,
|
description: string,
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ 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';
|
||||||
@@ -23,6 +21,7 @@ import { useProjectsStore } from '@/utils/useProjects';
|
|||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
|
|
||||||
const calendarStart = ref<Date | undefined>(undefined);
|
const calendarStart = ref<Date | undefined>(undefined);
|
||||||
const calendarEnd = ref<Date | undefined>(undefined);
|
const calendarEnd = ref<Date | undefined>(undefined);
|
||||||
@@ -57,39 +56,6 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -116,8 +82,9 @@ function onDatesChange({ start, end }: { start: Date; end: Date }) {
|
|||||||
|
|
||||||
function onRefresh() {
|
function onRefresh() {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['timeEntries', 'calendar'],
|
queryKey: ['timeEntries'],
|
||||||
});
|
});
|
||||||
|
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -136,8 +103,6 @@ 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"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"default": "./dist/solidtime-ui-lib.umd.cjs"
|
"default": "./dist/solidtime-ui-lib.umd.cjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"./style.css": "./dist/style.css",
|
"./style.css": "./dist/solidtime-ui-lib.css",
|
||||||
"./styles.css": "./styles.css",
|
"./styles.css": "./styles.css",
|
||||||
"./tailwind.theme.js": "./tailwind.theme.js"
|
"./tailwind.theme.js": "./tailwind.theme.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
ScissorsIcon,
|
ScissorsIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
StopIcon,
|
||||||
|
XMarkIcon,
|
||||||
} from '@heroicons/vue/20/solid';
|
} from '@heroicons/vue/20/solid';
|
||||||
import activityStatusPlugin, {
|
import activityStatusPlugin, {
|
||||||
type ActivityPeriod,
|
type ActivityPeriod,
|
||||||
@@ -94,8 +96,6 @@ 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
|
||||||
@@ -328,8 +328,6 @@ function handleCalendarContextMenu(event: MouseEvent) {
|
|||||||
if (!fcEvent) return;
|
if (!fcEvent) return;
|
||||||
|
|
||||||
const ext = fcEvent.extendedProps as CalendarExtendedProps;
|
const ext = fcEvent.extendedProps as CalendarExtendedProps;
|
||||||
if (ext.isRunning) return;
|
|
||||||
|
|
||||||
contextMenuTimeEntry.value = ext.timeEntry;
|
contextMenuTimeEntry.value = ext.timeEntry;
|
||||||
contextMenuCreateTime.value = null;
|
contextMenuCreateTime.value = null;
|
||||||
}
|
}
|
||||||
@@ -342,7 +340,16 @@ function handleContextEdit() {
|
|||||||
|
|
||||||
async function handleContextDuplicate() {
|
async function handleContextDuplicate() {
|
||||||
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
|
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
|
||||||
await props.duplicateTimeEntry(contextMenuTimeEntry.value);
|
const entry = contextMenuTimeEntry.value;
|
||||||
|
await props.createTimeEntry({
|
||||||
|
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,
|
||||||
|
});
|
||||||
emit('refresh');
|
emit('refresh');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,7 +361,38 @@ async function handleContextDelete() {
|
|||||||
|
|
||||||
async function handleContextSplit() {
|
async function handleContextSplit() {
|
||||||
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
|
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end === null) return;
|
||||||
await props.splitTimeEntry(contextMenuTimeEntry.value);
|
const entry = contextMenuTimeEntry.value;
|
||||||
|
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');
|
||||||
|
|
||||||
|
await props.updateTimeEntry({ ...entry, end: midpoint.utc().format() });
|
||||||
|
await props.createTimeEntry({
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
emit('refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleContextStop() {
|
||||||
|
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end !== null) return;
|
||||||
|
const entry = contextMenuTimeEntry.value;
|
||||||
|
await props.updateTimeEntry({
|
||||||
|
...entry,
|
||||||
|
end: getDayJsInstance()().utc().format(),
|
||||||
|
});
|
||||||
|
emit('refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleContextDiscard() {
|
||||||
|
if (!contextMenuTimeEntry.value || contextMenuTimeEntry.value.end !== null) return;
|
||||||
|
await props.deleteTimeEntry(contextMenuTimeEntry.value.id);
|
||||||
emit('refresh');
|
emit('refresh');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,7 +697,7 @@ onUnmounted(() => {
|
|||||||
</FullCalendar>
|
</FullCalendar>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent class="min-w-[160px]">
|
<ContextMenuContent class="min-w-[160px]">
|
||||||
<template v-if="contextMenuTimeEntry">
|
<template v-if="contextMenuTimeEntry && contextMenuTimeEntry.end !== null">
|
||||||
<ContextMenuItem class="space-x-3" @select="handleContextEdit()">
|
<ContextMenuItem class="space-x-3" @select="handleContextEdit()">
|
||||||
<PencilIcon class="w-4 h-4 text-icon-default" />
|
<PencilIcon class="w-4 h-4 text-icon-default" />
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
@@ -680,6 +718,19 @@ onUnmounted(() => {
|
|||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="contextMenuTimeEntry && contextMenuTimeEntry.end === null">
|
||||||
|
<ContextMenuItem class="space-x-3" @select="handleContextStop()">
|
||||||
|
<StopIcon class="w-4 h-4 text-icon-default" />
|
||||||
|
<span>Stop</span>
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
class="space-x-3 text-destructive"
|
||||||
|
@select="handleContextDiscard()">
|
||||||
|
<XMarkIcon class="w-4 h-4 text-icon-default" />
|
||||||
|
<span>Discard</span>
|
||||||
|
</ContextMenuItem>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ContextMenuItem class="space-x-3" @select="handleContextCreate()">
|
<ContextMenuItem class="space-x-3" @select="handleContextCreate()">
|
||||||
<PlusIcon class="w-4 h-4 text-icon-default" />
|
<PlusIcon class="w-4 h-4 text-icon-default" />
|
||||||
|
|||||||
Reference in New Issue
Block a user