Merge pull request #1225 from shariquerik/calendar-fixes

This commit is contained in:
Shariq Ansari 2025-09-05 19:13:54 +05:30 committed by GitHub
commit 0653c2293c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 143 additions and 148 deletions

View File

@ -1,78 +1,70 @@
<template>
<div>
<div
class="flex items-center justify-between text-ink-gray-7 [&>div]:w-full"
>
<Popover v-model:show="showOptions">
<template #target="{ togglePopover }">
<TextInput
<!-- Combobox Input -->
<div class="flex items-center w-full text-ink-gray-8 [&>div]:w-full">
<ComboboxRoot
:model-value="tempSelection"
:open="showOptions"
@update:open="(o) => (showOptions = o)"
@update:modelValue="onSelect"
:ignore-filter="true"
>
<ComboboxAnchor
class="flex w-full text-base items-center gap-1 rounded border border-outline-gray-2 bg-surface-white hover:border-outline-gray-3 focus:border-outline-gray-4 focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 px-2 py-1"
:class="[size === 'sm' ? 'h-7' : 'h-8 ', inputClass]"
@click="showOptions = true"
>
<ComboboxInput
ref="search"
type="text"
:size="size"
class="w-full"
variant="outline"
v-model="query"
:debounce="300"
autocomplete="off"
class="bg-transparent p-0 outline-none border-0 text-base text-ink-gray-8 h-full placeholder:text-ink-gray-4 w-full focus:outline-none focus:ring-0 focus:border-0"
:placeholder="placeholder"
@click="togglePopover"
@keydown="onKeydown"
:value="query"
@input="onInput"
@keydown.enter.prevent="handleEnter"
@keydown.escape.stop="showOptions = false"
/>
<FeatherIcon
name="chevron-down"
class="h-4 text-ink-gray-5 cursor-pointer"
@click.stop="showOptions = !showOptions"
/>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
class="z-10 mt-1 min-w-48 w-full max-w-md bg-surface-modal overflow-hidden rounded-lg shadow-2xl ring-1 ring-black ring-opacity-5"
position="popper"
:align="'start'"
@openAutoFocus.prevent
@closeAutoFocus.prevent
>
<template #suffix>
<FeatherIcon
name="chevron-down"
class="h-4 text-ink-gray-5"
@click.stop="togglePopover()"
/>
</template>
</TextInput>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<ul
v-if="options.length"
role="listbox"
class="p-1.5 max-h-[12rem] overflow-y-auto"
>
<li
v-for="(option, idx) in options"
:key="option.value"
role="option"
:aria-selected="idx === highlightIndex"
@click="selectOption(option)"
@mouseenter="highlightIndex = idx"
class="flex cursor-pointer items-center rounded px-2 py-1 text-base"
:class="{ 'bg-surface-gray-3': idx === highlightIndex }"
>
<UserAvatar class="mr-2" :user="option.value" size="lg" />
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
<div class="text-base font-medium">
{{ option.label }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ul>
<div
v-else
<ComboboxViewport class="max-h-60 overflow-auto p-1.5">
<ComboboxEmpty
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
>
<FeatherIcon v-if="fetchContacts" name="search" class="h-4" />
{{
fetchContacts
? __('No results found')
: __('Type an email address to add attendee')
}}
</div>
</div>
</div>
</template>
</Popover>
{{ emptyStateText }}
</ComboboxEmpty>
<ComboboxItem
v-for="option in options"
:key="option.value"
:value="option.value"
class="text-base leading-none text-ink-gray-7 rounded flex items-center px-2 py-1 relative select-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3 cursor-pointer"
@mousedown.prevent="onSelect(option.value, option)"
>
<UserAvatar class="mr-2" :user="option.value" size="lg" />
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
<div class="text-base font-medium">{{ option.label }}</div>
<div class="text-sm text-ink-gray-5">{{ option.value }}</div>
</div>
</ComboboxItem>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</div>
<!-- Selected Attendees -->
<div
v-if="values.length"
class="flex flex-col gap-2 mt-2 max-h-[165px] overflow-y-auto"
@ -105,8 +97,18 @@
<script setup>
import UserAvatar from '@/components/UserAvatar.vue'
import { createResource, TextInput, Popover } from 'frappe-ui'
import { ref, computed, nextTick, watch } from 'vue'
import { createResource } from 'frappe-ui'
import {
ComboboxRoot,
ComboboxAnchor,
ComboboxInput,
ComboboxPortal,
ComboboxContent,
ComboboxViewport,
ComboboxItem,
ComboboxEmpty,
} from 'reka-ui'
import { ref, computed, nextTick } from 'vue'
import { watchDebounced } from '@vueuse/core'
const props = defineProps({
@ -154,7 +156,7 @@ const query = ref('')
const text = ref('')
const showOptions = ref(false)
const optionsRef = ref(null)
const highlightIndex = ref(-1)
const tempSelection = ref(null)
const metaByEmail = computed(() => {
const out = {}
@ -225,6 +227,12 @@ const options = computed(() => {
return searchedContacts || []
})
const emptyStateText = computed(() =>
props.fetchContacts
? __('No results found')
: __('Type an email address to add attendee'),
)
function reload(val) {
if (!props.fetchContacts) return
@ -234,34 +242,38 @@ function reload(val) {
filterOptions.reload()
}
watch(
() => options.value,
() => {
highlightIndex.value = options.value.length ? 0 : -1
},
)
function selectOption(option) {
if (!option) return
addValue(option)
!error.value && (query.value = '')
showOptions.value = false
function onSelect(val, fullOption = null) {
if (!val) return
const optionObj = fullOption ||
options.value.find((o) => o.value === val) || {
name: 'new',
label: val,
value: val,
}
addValue(optionObj)
if (!error.value) {
query.value = ''
tempSelection.value = null
showOptions.value = false
nextTick(() => setFocus())
}
}
function onKeydown(e) {
if (e.key === 'Enter') {
if (highlightIndex.value >= 0 && options.value[highlightIndex.value]) {
selectOption(options.value[highlightIndex.value])
} else if (query.value) {
// Add entered email directly
selectOption({ name: 'new', label: query.value, value: query.value })
}
e.preventDefault()
} else if (e.key === 'Escape') {
showOptions.value = false
function handleEnter() {
if (query.value) {
onSelect(query.value, {
name: 'new',
label: query.value,
value: query.value,
})
}
}
function onInput(e) {
query.value = e.target.value
showOptions.value = true
}
const addValue = (option) => {
// Safeguard for falsy option
if (!option || !option.value) return
@ -315,7 +327,7 @@ const removeValue = (email) => {
}
function setFocus() {
search.value.$el.focus()
search.value?.focus?.()
}
defineExpose({ setFocus })

View File

@ -43,7 +43,10 @@
<!-- Event Details -->
<div v-if="mode == 'details'" class="flex flex-col overflow-y-auto">
<div class="flex items-start gap-2 px-4.5 py-3 pb-0">
<div
class="flex items-start gap-2 px-4.5 py-3 pb-0"
@dblclick="editDetails"
>
<div
class="mx-0.5 my-[5px] size-2.5 rounded-full cursor-pointer"
:style="{
@ -289,6 +292,9 @@
class="w-[220px]"
v-model="_event.referenceDocname"
:doctype="_event.referenceDoctype"
:filters="
_event.referenceDoctype === 'CRM Lead' ? { converted: 0 } : {}
"
variant="outline"
@update:model-value="sync"
/>
@ -329,6 +335,9 @@
variant="solid"
class="w-full"
:disabled="!dirty"
:loading="
mode === 'edit' ? events.setValue.loading : events.insert.loading
"
@click="saveEvent"
>
{{
@ -376,7 +385,7 @@ import {
createDocumentResource,
} from 'frappe-ui'
import ShortcutTooltip from '@/components/ShortcutTooltip.vue'
import { ref, computed, watch, h } from 'vue'
import { ref, computed, watch, h, inject } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
@ -402,6 +411,8 @@ const { $dialog } = globalStore()
const show = defineModel()
const event = defineModel('event')
const events = inject('events')
const _event = ref({})
const peoples = computed({
@ -527,6 +538,8 @@ function updateTime(t, fromTime = false) {
}
function saveEvent() {
if (!dirty.value) return
error.value = null
if (!_event.value.title) {
error.value = __('Title is required')

View File

@ -56,7 +56,7 @@
<Dropdown :options="actions(column)">
<template #default>
<Button
class="hidden group-hover:flex"
class="opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity"
icon="more-horizontal"
variant="ghost"
/>

View File

@ -115,36 +115,6 @@
/>
</div>
</div>
<div class="flex items-center">
<div class="text-base text-ink-gray-7 w-3/12">
{{ __('Link') }}
</div>
<div class="flex gap-2 w-9/12">
<FormControl
:class="_event.referenceDoctype ? 'w-20' : 'w-full'"
type="select"
:options="linkDoctypeOptions"
v-model="_event.referenceDoctype"
variant="outline"
:placeholder="__('Add Lead or Deal')"
@change="() => (_event.referenceDocname = '')"
/>
<Link
v-if="_event.referenceDoctype"
class="w-full"
v-model="_event.referenceDocname"
:doctype="_event.referenceDoctype"
variant="outline"
:placeholder="
__('Select {0}', [
_event.referenceDoctype == 'CRM Lead'
? __('Lead')
: __('Deal'),
])
"
/>
</div>
</div>
<div class="flex items-start">
<div class="text-base text-ink-gray-7 mt-1.5 w-3/12">
{{ __('Attendees') }}
@ -188,6 +158,7 @@
? __('Duplicate')
: __('Create')
"
:disabled="!dirty"
:loading="
mode === 'edit'
? eventsResource.setValue.loading
@ -200,7 +171,6 @@
</Dialog>
</template>
<script setup>
import Link from '@/components/Controls/Link.vue'
import Attendee from '@/components/Calendar/Attendee.vue'
import {
Switch,
@ -211,7 +181,6 @@ import {
TimePicker,
dayjs,
Dropdown,
FormControl,
} from 'frappe-ui'
import { globalStore } from '@/stores/global'
import { validateEmail } from '@/utils'
@ -256,6 +225,7 @@ const mode = computed(() => {
: 'create'
})
const oldEvent = ref({})
const _event = ref({
title: '',
description: '',
@ -271,6 +241,10 @@ const _event = ref({
event_participants: [],
})
const dirty = computed(() => {
return JSON.stringify(_event.value) !== JSON.stringify(oldEvent.value)
})
const peoples = computed({
get() {
return _event.value.event_participants || []
@ -306,6 +280,8 @@ onMounted(() => {
event_participants: props.event.event_participants || [],
}
oldEvent.value = JSON.parse(JSON.stringify(_event.value))
setTimeout(() => title.value?.el?.focus(), 100)
}
})
@ -376,8 +352,6 @@ function createEvent() {
color: _event.value.color,
reference_doctype: props.doctype,
reference_docname: props.docname,
reference_doctype: _event.value.referenceDoctype || props.doctype,
reference_docname: _event.value.referenceDocname || props.docname,
event_participants: _event.value.event_participants,
},
{
@ -407,8 +381,6 @@ function updateEvent() {
color: _event.value.color,
reference_doctype: props.doctype,
reference_docname: props.docname,
reference_doctype: _event.value.referenceDoctype || props.doctype,
reference_docname: _event.value.referenceDocname || props.docname,
event_participants: _event.value.event_participants,
},
{
@ -455,12 +427,6 @@ function deleteEvent() {
const toOptions = computed(() => buildEndTimeOptions(_event.value.fromTime))
const linkDoctypeOptions = [
{ label: '', value: '' },
{ label: __('Lead'), value: 'CRM Lead' },
{ label: __('Deal'), value: 'CRM Deal' },
]
const colors = Object.keys(colorMap).map((c) => ({
label: c.charAt(0).toUpperCase() + c.slice(1),
value: colorMap[c].color,

View File

@ -1,4 +1,5 @@
import { onMounted, onBeforeUnmount, unref } from 'vue'
import { isDialogOpen } from '@/utils/dialogs'
/**
* Generic global keyboard shortcuts composable.
@ -20,6 +21,7 @@ export function useKeyboardShortcuts(options) {
shortcuts = [],
ignoreTyping = true,
target = typeof window !== 'undefined' ? window : null,
skipWhenDialogOpen = true,
} = options || {}
function isTypingEvent(e) {
@ -49,6 +51,7 @@ export function useKeyboardShortcuts(options) {
const isActive = typeof active === 'function' ? active() : unref(active)
if (!isActive) return
if (isTypingEvent(e)) return
if (skipWhenDialogOpen && isDialogOpen()) return
for (const def of shortcuts) {
if (!def) continue

View File

@ -18,7 +18,6 @@
</LayoutHeader>
<div class="flex h-screen overflow-hidden">
<Calendar
v-if="events.data?.length"
class="flex-1 overflow-hidden"
ref="calendar"
:config="{
@ -93,11 +92,6 @@
</div>
</div>
</template>
<template #daily-header="{ parseDateWithDay, currentDate }">
<p class="ml-4 pb-2 text-base text-ink-gray-6">
{{ parseDateWithDay(currentDate) }}
</p>
</template>
</Calendar>
<!-- Event Panel Container -->
@ -144,7 +138,7 @@ import {
CalendarActiveEvent as activeEvent,
call,
} from 'frappe-ui'
import { onMounted, ref, computed } from 'vue'
import { onMounted, ref, computed, provide } from 'vue'
const { user } = sessionStore()
const { $dialog } = globalStore()
@ -188,6 +182,8 @@ const events = createListResource({
})),
})
provide('events', events)
const eventPanel = ref(null)
const showEventPanel = ref(false)
const event = ref({})
@ -248,6 +244,9 @@ function createEvent(_event) {
async function updateEvent(_event, afterDrag = false) {
if (!_event.id) return
_event.fromTime = dayjs(_event.fromTime, 'HH:mm').format('HH:mm')
_event.toTime = dayjs(_event.toTime, 'HH:mm').format('HH:mm')
if (
['duplicate', 'new'].includes(mode.value) &&
!['duplicate-event', 'new-event'].includes(_event.id) &&

View File

@ -3,6 +3,10 @@ import { reactive, ref } from 'vue'
let dialogs = ref([])
export function isDialogOpen() {
return dialogs.value.some((d) => d.show)
}
export let Dialogs = {
name: 'Dialogs',
render() {
@ -18,9 +22,7 @@ export let Dialogs = {
dialog.message && (
<p class="text-p-base text-ink-gray-7">{dialog.message}</p>
),
dialog.html && (
<div v-html={dialog.html} />
),
dialog.html && <div v-html={dialog.html} />,
<ErrorMessage class="mt-2" message={dialog.error} />,
]
},