Merge pull request #1225 from shariquerik/calendar-fixes
This commit is contained in:
commit
0653c2293c
@ -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 })
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) &&
|
||||
|
||||
@ -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} />,
|
||||
]
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user