mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
only show calendar toolbar after load complete to avoid layout shift
This commit is contained in:
@@ -497,10 +497,13 @@ test.describe('Visual Snapping', () => {
|
|||||||
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// Scroll so both the event (9:00) and target (14:00) are in viewport
|
||||||
|
await scrollCalendarToTime(page, '08:00:00');
|
||||||
|
|
||||||
const event = page.locator('.fc-event').first();
|
const event = page.locator('.fc-event').first();
|
||||||
await expect(event).toBeVisible();
|
await expect(event).toBeVisible();
|
||||||
|
|
||||||
// Drag event
|
// Capture target coordinates after scroll is settled
|
||||||
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
|
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
|
||||||
const targetBox = await targetSlot.boundingBox();
|
const targetBox = await targetSlot.boundingBox();
|
||||||
expect(targetBox).not.toBeNull();
|
expect(targetBox).not.toBeNull();
|
||||||
|
|||||||
@@ -1427,10 +1427,7 @@ test('test that bare integer in inline duration input with decimal format is int
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that bare integer in create modal is interpreted as minutes', async ({
|
test('test that bare integer in create modal is interpreted as minutes', async ({ page, ctx }) => {
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
await updateOrganizationSettingViaApi(ctx, {
|
await updateOrganizationSettingViaApi(ctx, {
|
||||||
interval_format: 'hours-minutes',
|
interval_format: 'hours-minutes',
|
||||||
number_format: 'comma-point',
|
number_format: 'comma-point',
|
||||||
|
|||||||
@@ -440,216 +440,233 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
:currency="currency"
|
:currency="currency"
|
||||||
:can-create-project="canCreateProject" />
|
:can-create-project="canCreateProject" />
|
||||||
|
|
||||||
<CalendarToolbar
|
<template v-if="!loading">
|
||||||
:view-title="viewTitle"
|
<CalendarToolbar
|
||||||
:active-view="activeView"
|
:view-title="viewTitle"
|
||||||
:settings="calendarSettings"
|
:active-view="activeView"
|
||||||
@prev="handlePrev"
|
:settings="calendarSettings"
|
||||||
@next="handleNext"
|
@prev="handlePrev"
|
||||||
@today="handleToday"
|
@next="handleNext"
|
||||||
@change-view="handleChangeView"
|
@today="handleToday"
|
||||||
@update:settings="onSettingsUpdate" />
|
@change-view="handleChangeView"
|
||||||
|
@update:settings="onSettingsUpdate" />
|
||||||
|
|
||||||
<ContextMenu v-model:open="contextMenuOpen">
|
<ContextMenu v-model:open="contextMenuOpen">
|
||||||
<ContextMenuTrigger
|
<ContextMenuTrigger
|
||||||
as="div"
|
as="div"
|
||||||
class="flex-1 min-h-0"
|
class="flex-1 min-h-0"
|
||||||
@contextmenu="handleCalendarContextMenu">
|
@contextmenu="handleCalendarContextMenu">
|
||||||
<div
|
|
||||||
ref="rootRef"
|
|
||||||
class="fc h-full flex flex-col bg-default-background text-foreground font-inherit border border-border border-l-transparent overflow-hidden">
|
|
||||||
<div
|
<div
|
||||||
class="fc-header-scroll flex border-b border-border shrink-0 sticky top-0 z-10 bg-default-background">
|
ref="rootRef"
|
||||||
|
class="fc h-full flex flex-col bg-default-background text-foreground font-inherit border border-border border-l-transparent overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="shrink-0 bg-background border-r border-border"
|
class="fc-header-scroll flex border-b border-border shrink-0 sticky top-0 z-10 bg-default-background">
|
||||||
:style="{
|
|
||||||
width: TIME_AXIS_WIDTH + 'px',
|
|
||||||
minWidth: TIME_AXIS_WIDTH + 'px',
|
|
||||||
}"></div>
|
|
||||||
<div
|
|
||||||
class="grid flex-1 min-w-0"
|
|
||||||
:style="{
|
|
||||||
gridTemplateColumns: 'repeat(' + viewDays.length + ', 1fr)',
|
|
||||||
}">
|
|
||||||
<div
|
|
||||||
v-for="day in viewDays"
|
|
||||||
:key="day.format('YYYY-MM-DD')"
|
|
||||||
class="fc-col-header-cell border-r border-b border-border px-2 py-3 bg-default-background text-center"
|
|
||||||
:class="{
|
|
||||||
'bg-secondary': isToday(day),
|
|
||||||
'fc-day-today': isToday(day),
|
|
||||||
}"
|
|
||||||
:data-date="day.format('YYYY-MM-DD')">
|
|
||||||
<FullCalendarDayHeader
|
|
||||||
:date="day"
|
|
||||||
:is-today="isToday(day)"
|
|
||||||
:total-seconds="dailyTotals[day.format('YYYY-MM-DD')] || 0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref="scrollerRef" class="fc-scroller">
|
|
||||||
<div class="flex min-w-0">
|
|
||||||
<div
|
<div
|
||||||
class="shrink-0 bg-background border-r border-border"
|
class="shrink-0 bg-background border-r border-border"
|
||||||
:style="{
|
:style="{
|
||||||
width: TIME_AXIS_WIDTH + 'px',
|
width: TIME_AXIS_WIDTH + 'px',
|
||||||
minWidth: TIME_AXIS_WIDTH + 'px',
|
minWidth: TIME_AXIS_WIDTH + 'px',
|
||||||
|
}"></div>
|
||||||
|
<div
|
||||||
|
class="grid flex-1 min-w-0"
|
||||||
|
:style="{
|
||||||
|
gridTemplateColumns: 'repeat(' + viewDays.length + ', 1fr)',
|
||||||
}">
|
}">
|
||||||
<div
|
<div
|
||||||
v-for="slot in slots"
|
v-for="day in viewDays"
|
||||||
:key="slot.time"
|
:key="day.format('YYYY-MM-DD')"
|
||||||
class="fc-timegrid-slot fc-timegrid-slot-label relative text-right border-t border-border pr-1.5 pt-2 box-border"
|
class="fc-col-header-cell border-r border-b border-border px-2 py-3 bg-default-background text-center"
|
||||||
:class="{
|
:class="{
|
||||||
'fc-timegrid-slot-minor border-t-transparent': !slot.isHour,
|
'bg-secondary': isToday(day),
|
||||||
|
'fc-day-today': isToday(day),
|
||||||
}"
|
}"
|
||||||
:data-time="slot.time"
|
:data-date="day.format('YYYY-MM-DD')">
|
||||||
:style="{ height: SLOT_HEIGHT + 'px' }">
|
<FullCalendarDayHeader
|
||||||
<span
|
:date="day"
|
||||||
v-if="slot.isHour"
|
:is-today="isToday(day)"
|
||||||
class="fc-timegrid-slot-label-cushion text-[0.8125rem] text-muted-foreground leading-none block">
|
:total-seconds="
|
||||||
{{ formatSlotLabel(slot.minutes / 60) }}
|
dailyTotals[day.format('YYYY-MM-DD')] || 0
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex-1 min-w-0 relative"
|
|
||||||
@pointerdown="onSlotPointerDown($event)">
|
|
||||||
<div
|
|
||||||
class="bg-background"
|
|
||||||
:style="{ height: totalGridHeight + 'px' }">
|
|
||||||
<div
|
|
||||||
v-for="slot in slots"
|
|
||||||
:key="'lane-' + slot.time"
|
|
||||||
class="fc-timegrid-slot fc-timegrid-slot-lane border-t border-border box-border"
|
|
||||||
:class="{
|
|
||||||
'fc-timegrid-slot-minor border-dotted': !slot.isHour,
|
|
||||||
}"
|
|
||||||
:data-time="slot.time"
|
|
||||||
:style="{ height: SLOT_HEIGHT + 'px' }"></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="grid absolute inset-0 pointer-events-none min-w-0"
|
|
||||||
:style="{
|
|
||||||
gridTemplateColumns: 'repeat(' + viewDays.length + ', 1fr)',
|
|
||||||
}">
|
|
||||||
<CalendarDayColumn
|
|
||||||
v-for="day in viewDays"
|
|
||||||
:key="day.format('YYYY-MM-DD')"
|
|
||||||
:day-str="day.format('YYYY-MM-DD')"
|
|
||||||
:total-grid-height="totalGridHeight"
|
|
||||||
:has-activity-status="
|
|
||||||
dayHasActivityStatus(day.format('YYYY-MM-DD'))
|
|
||||||
"
|
|
||||||
:day-events="eventsByDay[day.format('YYYY-MM-DD')] || []"
|
|
||||||
:get-event-style="getEventStyle"
|
|
||||||
:get-event-opacity-class="getEventOpacityClass"
|
|
||||||
:get-event-duration-seconds="getEventDurationSeconds"
|
|
||||||
:is-dragging="isDragging"
|
|
||||||
:drag-event-id="dragEventId"
|
|
||||||
:drag-preview="dragPreviewsByDay[day.format('YYYY-MM-DD')]"
|
|
||||||
:resize-event-id="resizeEventId"
|
|
||||||
:resize-cross-day-preview="
|
|
||||||
isResizing
|
|
||||||
? resizeCrossDayPreviewsByDay[
|
|
||||||
day.format('YYYY-MM-DD')
|
|
||||||
]
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
:show-now-indicator="isToday(day) && nowIndicatorTop >= 0"
|
|
||||||
:now-indicator-top="nowIndicatorTop"
|
|
||||||
:activity-boxes="
|
|
||||||
activityBoxesForDay(day.format('YYYY-MM-DD'))
|
|
||||||
"
|
|
||||||
:get-activity-box-label="getActivityBoxLabel"
|
|
||||||
:get-activity-box-activities="getActivityBoxActivities"
|
|
||||||
:get-activity-percentage="getActivityPercentage"
|
|
||||||
:get-activity-text="getActivityText"
|
|
||||||
:show-selection="isSelecting || showCreateTimeEntryModal"
|
|
||||||
:is-selection-start="
|
|
||||||
selectionDay === day.format('YYYY-MM-DD')
|
|
||||||
"
|
|
||||||
:is-selection-intermediate="
|
|
||||||
selectionIntermediateDays.has(day.format('YYYY-MM-DD'))
|
|
||||||
"
|
|
||||||
:is-selection-end="
|
|
||||||
selectionEndDay === day.format('YYYY-MM-DD')
|
|
||||||
"
|
|
||||||
:selection-top="selectionTop"
|
|
||||||
:selection-height="selectionHeight"
|
|
||||||
:selection-end-top="selectionEndTop"
|
|
||||||
:selection-end-height="selectionEndHeight"
|
|
||||||
@event-pointerdown="
|
|
||||||
(e, dayEvent) =>
|
|
||||||
onEventPointerDown(e, dayEvent.event, dayEvent)
|
|
||||||
"
|
|
||||||
@event-keydown-enter="
|
|
||||||
(dayEvent) => {
|
|
||||||
selectedTimeEntry = dayEvent.event.timeEntry;
|
|
||||||
showEditTimeEntryModal = true;
|
|
||||||
}
|
|
||||||
"
|
|
||||||
@resizer-pointerdown="
|
|
||||||
(e, dayEvent, edge) =>
|
|
||||||
onResizerPointerDown(
|
|
||||||
e,
|
|
||||||
dayEvent.event,
|
|
||||||
dayEvent,
|
|
||||||
edge,
|
|
||||||
day.format('YYYY-MM-DD')
|
|
||||||
)
|
|
||||||
" />
|
" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
|
|
||||||
<ContextMenuContent class="min-w-[160px]">
|
<div ref="scrollerRef" class="fc-scroller">
|
||||||
<template v-if="contextMenuTimeEntry && contextMenuTimeEntry.end !== null">
|
<div class="flex min-w-0">
|
||||||
<ContextMenuItem class="space-x-3" @select="handleContextEdit()">
|
<div
|
||||||
<PencilIcon class="w-4 h-4 text-icon-default" />
|
class="shrink-0 bg-background border-r border-border"
|
||||||
<span>Edit</span>
|
:style="{
|
||||||
</ContextMenuItem>
|
width: TIME_AXIS_WIDTH + 'px',
|
||||||
<ContextMenuItem class="space-x-3" @select="handleContextDuplicate()">
|
minWidth: TIME_AXIS_WIDTH + 'px',
|
||||||
<DocumentDuplicateIcon class="w-4 h-4 text-icon-default" />
|
}">
|
||||||
<span>Duplicate</span>
|
<div
|
||||||
</ContextMenuItem>
|
v-for="slot in slots"
|
||||||
<ContextMenuItem class="space-x-3" @select="handleContextSplit()">
|
:key="slot.time"
|
||||||
<ScissorsIcon class="w-4 h-4 text-icon-default" />
|
class="fc-timegrid-slot fc-timegrid-slot-label relative text-right border-t border-border pr-1.5 pt-2 box-border"
|
||||||
<span>Split</span>
|
:class="{
|
||||||
</ContextMenuItem>
|
'fc-timegrid-slot-minor border-t-transparent':
|
||||||
<ContextMenuSeparator />
|
!slot.isHour,
|
||||||
<ContextMenuItem
|
}"
|
||||||
class="space-x-3 text-destructive"
|
:data-time="slot.time"
|
||||||
@select="handleContextDelete()">
|
:style="{ height: SLOT_HEIGHT + 'px' }">
|
||||||
<TrashIcon class="w-4 h-4 text-icon-default" />
|
<span
|
||||||
<span>Delete</span>
|
v-if="slot.isHour"
|
||||||
</ContextMenuItem>
|
class="fc-timegrid-slot-label-cushion text-[0.8125rem] text-muted-foreground leading-none block">
|
||||||
</template>
|
{{ formatSlotLabel(slot.minutes / 60) }}
|
||||||
<template v-else-if="contextMenuTimeEntry && contextMenuTimeEntry.end === null">
|
</span>
|
||||||
<ContextMenuItem class="space-x-3" @select="handleContextStop()">
|
</div>
|
||||||
<StopIcon class="w-4 h-4 text-icon-default" />
|
</div>
|
||||||
<span>Stop</span>
|
|
||||||
</ContextMenuItem>
|
<div
|
||||||
<ContextMenuSeparator />
|
class="flex-1 min-w-0 relative"
|
||||||
<ContextMenuItem
|
@pointerdown="onSlotPointerDown($event)">
|
||||||
class="space-x-3 text-destructive"
|
<div
|
||||||
@select="handleContextDiscard()">
|
class="bg-background"
|
||||||
<XMarkIcon class="w-4 h-4 text-icon-default" />
|
:style="{ height: totalGridHeight + 'px' }">
|
||||||
<span>Discard</span>
|
<div
|
||||||
</ContextMenuItem>
|
v-for="slot in slots"
|
||||||
</template>
|
:key="'lane-' + slot.time"
|
||||||
<template v-else>
|
class="fc-timegrid-slot fc-timegrid-slot-lane border-t border-border box-border"
|
||||||
<ContextMenuItem class="space-x-3" @select="handleContextCreate()">
|
:class="{
|
||||||
<PlusIcon class="w-4 h-4 text-icon-default" />
|
'fc-timegrid-slot-minor border-dotted':
|
||||||
<span>Create Time Entry</span>
|
!slot.isHour,
|
||||||
</ContextMenuItem>
|
}"
|
||||||
</template>
|
:data-time="slot.time"
|
||||||
</ContextMenuContent>
|
:style="{ height: SLOT_HEIGHT + 'px' }"></div>
|
||||||
</ContextMenu>
|
</div>
|
||||||
|
<div
|
||||||
|
class="grid absolute inset-0 pointer-events-none min-w-0"
|
||||||
|
:style="{
|
||||||
|
gridTemplateColumns:
|
||||||
|
'repeat(' + viewDays.length + ', 1fr)',
|
||||||
|
}">
|
||||||
|
<CalendarDayColumn
|
||||||
|
v-for="day in viewDays"
|
||||||
|
:key="day.format('YYYY-MM-DD')"
|
||||||
|
:day-str="day.format('YYYY-MM-DD')"
|
||||||
|
:total-grid-height="totalGridHeight"
|
||||||
|
:has-activity-status="
|
||||||
|
dayHasActivityStatus(day.format('YYYY-MM-DD'))
|
||||||
|
"
|
||||||
|
:day-events="
|
||||||
|
eventsByDay[day.format('YYYY-MM-DD')] || []
|
||||||
|
"
|
||||||
|
:get-event-style="getEventStyle"
|
||||||
|
:get-event-opacity-class="getEventOpacityClass"
|
||||||
|
:get-event-duration-seconds="getEventDurationSeconds"
|
||||||
|
:is-dragging="isDragging"
|
||||||
|
:drag-event-id="dragEventId"
|
||||||
|
:drag-preview="
|
||||||
|
dragPreviewsByDay[day.format('YYYY-MM-DD')]
|
||||||
|
"
|
||||||
|
:resize-event-id="resizeEventId"
|
||||||
|
:resize-cross-day-preview="
|
||||||
|
isResizing
|
||||||
|
? resizeCrossDayPreviewsByDay[
|
||||||
|
day.format('YYYY-MM-DD')
|
||||||
|
]
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:show-now-indicator="
|
||||||
|
isToday(day) && nowIndicatorTop >= 0
|
||||||
|
"
|
||||||
|
:now-indicator-top="nowIndicatorTop"
|
||||||
|
:activity-boxes="
|
||||||
|
activityBoxesForDay(day.format('YYYY-MM-DD'))
|
||||||
|
"
|
||||||
|
:get-activity-box-label="getActivityBoxLabel"
|
||||||
|
:get-activity-box-activities="getActivityBoxActivities"
|
||||||
|
:get-activity-percentage="getActivityPercentage"
|
||||||
|
:get-activity-text="getActivityText"
|
||||||
|
:show-selection="
|
||||||
|
isSelecting || showCreateTimeEntryModal
|
||||||
|
"
|
||||||
|
:is-selection-start="
|
||||||
|
selectionDay === day.format('YYYY-MM-DD')
|
||||||
|
"
|
||||||
|
:is-selection-intermediate="
|
||||||
|
selectionIntermediateDays.has(
|
||||||
|
day.format('YYYY-MM-DD')
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:is-selection-end="
|
||||||
|
selectionEndDay === day.format('YYYY-MM-DD')
|
||||||
|
"
|
||||||
|
:selection-top="selectionTop"
|
||||||
|
:selection-height="selectionHeight"
|
||||||
|
:selection-end-top="selectionEndTop"
|
||||||
|
:selection-end-height="selectionEndHeight"
|
||||||
|
@event-pointerdown="
|
||||||
|
(e, dayEvent) =>
|
||||||
|
onEventPointerDown(e, dayEvent.event, dayEvent)
|
||||||
|
"
|
||||||
|
@event-keydown-enter="
|
||||||
|
(dayEvent) => {
|
||||||
|
selectedTimeEntry = dayEvent.event.timeEntry;
|
||||||
|
showEditTimeEntryModal = true;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@resizer-pointerdown="
|
||||||
|
(e, dayEvent, edge) =>
|
||||||
|
onResizerPointerDown(
|
||||||
|
e,
|
||||||
|
dayEvent.event,
|
||||||
|
dayEvent,
|
||||||
|
edge,
|
||||||
|
day.format('YYYY-MM-DD')
|
||||||
|
)
|
||||||
|
" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
|
||||||
|
<ContextMenuContent class="min-w-[160px]">
|
||||||
|
<template v-if="contextMenuTimeEntry && contextMenuTimeEntry.end !== null">
|
||||||
|
<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-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>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,7 @@ function updateDuration() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seconds = parseTimeInput(
|
const seconds = parseTimeInput(input, organization?.value?.number_format, 'hours');
|
||||||
input,
|
|
||||||
organization?.value?.number_format,
|
|
||||||
'hours'
|
|
||||||
);
|
|
||||||
if (seconds !== null && seconds > 0) {
|
if (seconds !== null && seconds > 0) {
|
||||||
model.value = seconds;
|
model.value = seconds;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user