feat: keep event in sync and show discard changes option if dirty

This commit is contained in:
Shariq Ansari 2025-08-08 16:58:41 +05:30
parent 3f4601efa0
commit 68ac2b80ff
2 changed files with 148 additions and 105 deletions

View File

@ -77,7 +77,7 @@
<div class="px-4.5 py-3"> <div class="px-4.5 py-3">
<TextInput <TextInput
ref="eventTitle" ref="eventTitle"
v-model="_event.title" v-model="event.title"
:debounce="500" :debounce="500"
:placeholder="__('Event title')" :placeholder="__('Event title')"
> >
@ -86,7 +86,7 @@
<div <div
class="ml-0.5 size-2.5 rounded-full cursor-pointer" class="ml-0.5 size-2.5 rounded-full cursor-pointer"
:style="{ :style="{
backgroundColor: _event.color || '#30A66D', backgroundColor: event.color || '#30A66D',
}" }"
/> />
</Dropdown> </Dropdown>
@ -95,7 +95,7 @@
</div> </div>
<div class="flex justify-between py-2.5 px-4.5 text-ink-gray-6"> <div class="flex justify-between py-2.5 px-4.5 text-ink-gray-6">
<div class="flex items-center"> <div class="flex items-center">
<Switch v-model="_event.isFullDay" /> <Switch v-model="event.isFullDay" />
<div class="ml-2"> <div class="ml-2">
{{ __('All day') }} {{ __('All day') }}
</div> </div>
@ -113,7 +113,7 @@
<DatePicker <DatePicker
:class="['[&_input]:w-[216px]']" :class="['[&_input]:w-[216px]']"
variant="outline" variant="outline"
:value="_event.fromDate" :value="event.fromDate"
:formatter="(date) => getFormat(date, 'MMM D, YYYY')" :formatter="(date) => getFormat(date, 'MMM D, YYYY')"
:placeholder="__('May 1, 2025')" :placeholder="__('May 1, 2025')"
@update:modelValue="(date) => updateDate(date, true)" @update:modelValue="(date) => updateDate(date, true)"
@ -129,16 +129,16 @@
</div> </div>
</div> </div>
<div <div
v-if="!_event.isFullDay" v-if="!event.isFullDay"
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7" class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7"
> >
<div class="w-20">{{ __('Time') }}</div> <div class="w-20">{{ __('Time') }}</div>
<div class="flex items-center gap-x-3"> <div class="flex items-center gap-x-3">
<TimePicker <TimePicker
v-if="!_event.isFullDay" v-if="!event.isFullDay"
class="max-w-[102px]" class="max-w-[102px]"
variant="outline" variant="outline"
:value="_event.fromTime" :value="event.fromTime"
:placeholder="__('Start Time')" :placeholder="__('Start Time')"
@update:modelValue="(time) => updateTime(time, true)" @update:modelValue="(time) => updateTime(time, true)"
> >
@ -153,7 +153,7 @@
<TimePicker <TimePicker
class="max-w-[102px]" class="max-w-[102px]"
variant="outline" variant="outline"
:value="_event.toTime" :value="event.toTime"
:placeholder="__('End Time')" :placeholder="__('End Time')"
@update:modelValue="(time) => updateTime(time)" @update:modelValue="(time) => updateTime(time)"
> >
@ -173,8 +173,8 @@
<TextEditor <TextEditor
editor-class="!prose-sm overflow-auto min-h-[20px] max-h-32 px-2 rounded placeholder-ink-gray-4 focus:bg-surface-white focus:ring-0 text-ink-gray-8 transition-colors" editor-class="!prose-sm overflow-auto min-h-[20px] max-h-32 px-2 rounded placeholder-ink-gray-4 focus:bg-surface-white focus:ring-0 text-ink-gray-8 transition-colors"
:bubbleMenu="true" :bubbleMenu="true"
:content="_event.description" :content="event.description"
@change="(val) => (_event.description = val)" @change="(val) => (event.description = val)"
:placeholder="__('Add description')" :placeholder="__('Add description')"
/> />
</div> </div>
@ -200,6 +200,7 @@
import EditIcon from '@/components/Icons/EditIcon.vue' import EditIcon from '@/components/Icons/EditIcon.vue'
import DescriptionIcon from '@/components/Icons/DescriptionIcon.vue' import DescriptionIcon from '@/components/Icons/DescriptionIcon.vue'
import TimePicker from './TimePicker.vue' import TimePicker from './TimePicker.vue'
import { globalStore } from '@/stores/global'
import { getFormat } from '@/utils' import { getFormat } from '@/utils'
import { import {
TextInput, TextInput,
@ -212,7 +213,7 @@ import {
CalendarColorMap as colorMap, CalendarColorMap as colorMap,
CalendarActiveEvent as activeEvent, CalendarActiveEvent as activeEvent,
} from 'frappe-ui' } from 'frappe-ui'
import { ref, computed, watch, nextTick, h } from 'vue' import { ref, computed, watch, h } from 'vue'
const props = defineProps({ const props = defineProps({
event: { event: {
@ -227,6 +228,8 @@ const props = defineProps({
const emit = defineEmits(['save', 'edit', 'delete', 'details', 'close']) const emit = defineEmits(['save', 'edit', 'delete', 'details', 'close'])
const { $dialog } = globalStore()
const show = defineModel() const show = defineModel()
const title = computed(() => { const title = computed(() => {
@ -236,59 +239,59 @@ const title = computed(() => {
return __('Duplicate event') return __('Duplicate event')
}) })
const _event = ref({})
const eventTitle = ref(null) const eventTitle = ref(null)
const error = ref(null) const error = ref(null)
watch( const oldEvent = ref(null)
() => props.event, const dirty = computed(() => {
(newEvent) => { return JSON.stringify(oldEvent.value) !== JSON.stringify(props.event)
error.value = null })
nextTick(() => { watch(
if (props.mode === 'create' && _event.value.id === 'new-event') { [() => props.mode, () => props.event],
_event.value.fromDate = newEvent.fromDate () => {
_event.value.toDate = newEvent.toDate focusOnTitle()
_event.value.fromTime = newEvent.fromTime oldEvent.value = { ...props.event }
_event.value.toTime = newEvent.toTime
} else {
_event.value = { ...newEvent }
}
})
setTimeout(() => eventTitle.value?.el?.focus(), 100)
}, },
{ immediate: true }, { immediate: true },
) )
function focusOnTitle() {
setTimeout(() => {
if (['edit', 'create', 'duplicate'].includes(props.mode)) {
eventTitle.value?.el?.focus()
}
}, 100)
}
function updateDate(d) { function updateDate(d) {
_event.value.fromDate = d props.event.fromDate = d
_event.value.toDate = d props.event.toDate = d
} }
function updateTime(t, fromTime = false) { function updateTime(t, fromTime = false) {
error.value = null error.value = null
let oldTo = _event.value.toTime || _event.value.fromTime let oldTo = props.event.toTime || props.event.fromTime
if (fromTime) { if (fromTime) {
_event.value.fromTime = t props.event.fromTime = t
if (!_event.value.toTime) { if (!props.event.toTime) {
const hour = parseInt(t.split(':')[0]) const hour = parseInt(t.split(':')[0])
const minute = parseInt(t.split(':')[1]) const minute = parseInt(t.split(':')[1])
_event.value.toTime = `${hour + 1}:${minute}` props.event.toTime = `${hour + 1}:${minute}`
} }
} else { } else {
_event.value.toTime = t props.event.toTime = t
} }
if (_event.value.toTime && _event.value.fromTime) { if (props.event.toTime && props.event.fromTime) {
const diff = dayjs(_event.value.toDate + ' ' + _event.value.toTime).diff( const diff = dayjs(props.event.toDate + ' ' + props.event.toTime).diff(
dayjs(_event.value.fromDate + ' ' + _event.value.fromTime), dayjs(props.event.fromDate + ' ' + props.event.fromTime),
'minute', 'minute',
) )
if (diff < 0) { if (diff < 0) {
_event.value.toTime = oldTo props.event.toTime = oldTo
error.value = __('End time should be after start time') error.value = __('End time should be after start time')
return return
} }
@ -297,49 +300,99 @@ function updateTime(t, fromTime = false) {
function saveEvent() { function saveEvent() {
error.value = null error.value = null
if (!_event.value.title) { if (!props.event.title) {
error.value = __('Title is required') error.value = __('Title is required')
eventTitle.value.el.focus() eventTitle.value.el.focus()
return return
} }
_event.value.fromDateTime = oldEvent.value = { ...props.event }
_event.value.fromDate + ' ' + _event.value.fromTime emit('save', props.event)
_event.value.toDateTime = _event.value.toDate + ' ' + _event.value.toTime
emit('save', _event.value)
} }
function editDetails() { function editDetails() {
emit('edit', _event.value) emit('edit', props.event)
} }
function duplicateEvent() { function duplicateEvent() {
emit('duplicate', _event.value) if (dirty.value) {
showDiscardChangesModal(() => reset())
} else {
emit('duplicate', props.event)
}
} }
function deleteEvent() { function deleteEvent() {
emit('delete', _event.value.id) emit('delete', props.event.id)
} }
function details() { function details() {
emit('details', _event.value) if (dirty.value) {
showDiscardChangesModal(() => reset())
} else {
emit('details', props.event)
}
} }
function close() { function close() {
show.value = false const _close = () => {
activeEvent.value = '' show.value = false
emit('close', _event.value) activeEvent.value = ''
emit('close', props.event)
}
if (dirty.value) {
showDiscardChangesModal(() => {
reset()
if (props.event.id === 'new-event') _close()
})
} else {
if (props.event.id === 'duplicate-event')
showDiscardChangesModal(() => _close())
else _close()
}
}
function reset() {
Object.assign(props.event, oldEvent.value)
}
function showDiscardChangesModal(action) {
$dialog({
title: __('Discard unsaved changes?'),
message: __(
'Are you sure you want to discard unsaved changes to this event?',
),
actions: [
{
label: __('Cancel'),
onClick: (close) => {
close()
},
},
{
label: __('Discard'),
variant: 'solid',
onClick: (close) => {
action()
close()
},
},
],
})
} }
const formattedDateTime = computed(() => { const formattedDateTime = computed(() => {
const date = dayjs(props.event.fromDate)
if (props.event.isFullDay) { if (props.event.isFullDay) {
return `${__('All day')} - ${dayjs(props.event.fromDateTime).format('ddd, D MMM YYYY')}` return `${__('All day')} - ${date.format('ddd, D MMM YYYY')}`
} }
const start = dayjs(props.event.fromDateTime) const start = dayjs(props.event.fromDate + ' ' + props.event.fromTime)
const end = dayjs(props.event.toDateTime) const end = dayjs(props.event.toDate + ' ' + props.event.toTime)
return `${start.format('h:mm a')} - ${end.format('h:mm a')} ${start.format('ddd, D MMM YYYY')}`
return `${start.format('h:mm a')} - ${end.format('h:mm a')} ${date.format('ddd, D MMM YYYY')}`
}) })
const colors = Object.keys(colorMap).map((color) => ({ const colors = Object.keys(colorMap).map((color) => ({
@ -350,7 +403,7 @@ const colors = Object.keys(colorMap).map((color) => ({
style: { backgroundColor: colorMap[color].color }, style: { backgroundColor: colorMap[color].color },
}), }),
onClick: () => { onClick: () => {
_event.value.color = colorMap[color].color props.event.color = colorMap[color].color
}, },
})) }))
</script> </script>

View File

@ -133,17 +133,26 @@ const events = createListResource({
filters: { status: 'Open', owner: user }, filters: { status: 'Open', owner: user },
auto: true, auto: true,
transform: (data) => { transform: (data) => {
return data.map((event) => ({ return data.map((event) => {
id: event.name, let fromDate = dayjs(event.starts_on).format('YYYY-MM-DD')
title: event.subject, let toDate = dayjs(event.ends_on).format('YYYY-MM-DD')
description: event.description, let fromTime = dayjs(event.starts_on).format('HH:mm')
status: event.status, let toTime = dayjs(event.ends_on).format('HH:mm')
fromDate: event.starts_on,
toDate: event.ends_on, return {
isFullDay: event.all_day, id: event.name,
eventType: event.event_type, title: event.subject,
color: event.color, description: event.description,
})) status: event.status,
fromDate,
toDate,
fromTime,
toTime,
isFullDay: event.all_day,
eventType: event.event_type,
color: event.color,
}
})
}, },
insert: { insert: {
onSuccess: () => events.reload(), onSuccess: () => events.reload(),
@ -175,21 +184,13 @@ function createEvent(_event) {
{ {
subject: _event.title, subject: _event.title,
description: _event.description, description: _event.description,
starts_on: _event.fromDateTime, starts_on: _event.fromDate + ' ' + _event.fromTime,
ends_on: _event.toDateTime, ends_on: _event.toDate + ' ' + _event.toTime,
all_day: _event.isFullDay, all_day: _event.isFullDay || false,
event_type: _event.eventType, event_type: _event.eventType,
color: _event.color, color: _event.color,
}, },
{ { onSuccess: (e) => showDetails({ id: e.name }) },
onSuccess: (e) => {
_event.id = e.name
event.value = _event
showEventPanel.value = true
activeEvent.value = e.name
mode.value = 'details'
},
},
) )
} }
@ -202,15 +203,15 @@ function updateEvent(_event) {
name: _event.id, name: _event.id,
subject: _event.title, subject: _event.title,
description: _event.description, description: _event.description,
starts_on: _event.fromDateTime, starts_on: _event.fromDate + ' ' + _event.fromTime,
ends_on: _event.toDateTime, ends_on: _event.toDate + ' ' + _event.toTime,
all_day: _event.isFullDay, all_day: _event.isFullDay,
event_type: _event.eventType, event_type: _event.eventType,
color: _event.color, color: _event.color,
}, },
{ {
onSuccess: () => { onSuccess: (e) => {
mode.value = 'details' showEventPanel.value && showDetails({ id: e.name })
}, },
}, },
) )
@ -225,8 +226,6 @@ function deleteEvent(eventID) {
$dialog({ $dialog({
title: __('Delete'), title: __('Delete'),
message: __('Are you sure you want to delete this event?'), message: __('Are you sure you want to delete this event?'),
variant: 'solid',
theme: 'red',
actions: [ actions: [
{ {
label: __('Delete'), label: __('Delete'),
@ -264,7 +263,7 @@ function showDetails(e) {
) )
showEventPanel.value = true showEventPanel.value = true
event.value = { ..._e } event.value = events.data.find((ev) => ev.id === _e.id) || _e
activeEvent.value = _e.id activeEvent.value = _e.id
mode.value = 'details' mode.value = 'details'
} }
@ -278,7 +277,7 @@ function editDetails(e) {
) )
showEventPanel.value = true showEventPanel.value = true
event.value = { ..._e } event.value = events.data.find((ev) => ev.id === _e.id) || _e
activeEvent.value = _e.id activeEvent.value = _e.id
mode.value = 'edit' mode.value = 'edit'
} }
@ -291,43 +290,34 @@ function newEvent(e, duplicate = false) {
let fromTime = e.fromTime let fromTime = e.fromTime
let toTime = e.toTime let toTime = e.toTime
let fromDate = e.fromDate let fromDate = e.fromDate
let toDate = e.toDate
if (!duplicate) { if (!duplicate) {
let t = getFromToTime(e.time) let t = getFromToTime(e.time)
fromTime = t[0] fromTime = t[0]
toTime = t[1] toTime = t[1]
fromDate = dayjs(e.date).format('YYYY-MM-DD') fromDate = dayjs(e.date).format('YYYY-MM-DD')
e = { fromDate, fromTime, toTime } toDate = fromDate
e = { fromDate, toDate, fromTime, toTime }
} }
showEventPanel.value = true
event.value = { event.value = {
id: duplicate ? 'duplicate-event' : 'new-event', id: duplicate ? 'duplicate-event' : 'new-event',
title: duplicate ? `${e.title} (Copy)` : '', title: duplicate ? `${e.title} (Copy)` : '',
description: e.description || '', description: e.description || '',
date: fromDate, date: fromDate,
fromDate: fromDate, fromDate,
toDate: fromDate, toDate,
fromTime, fromTime,
toTime, toTime,
isFullDay: e.isFullDay, isFullDay: e.isFullDay || false,
eventType: e.eventType || 'Public', eventType: e.eventType || 'Public',
color: e.color || 'green', color: e.color || 'green',
} }
events.data.push({ events.data.push(event.value)
id: duplicate ? 'duplicate-event' : 'new-event',
title: duplicate ? `${e.title} (Copy)` : '',
description: e.description || '',
status: 'Open',
eventType: e.eventType || 'Public',
fromDate: fromDate + ' ' + fromTime,
toDate: fromDate + ' ' + toTime,
color: e.color || 'green',
isFullDay: e.isFullDay,
})
showEventPanel.value = true
activeEvent.value = duplicate ? 'duplicate-event' : 'new-event' activeEvent.value = duplicate ? 'duplicate-event' : 'new-event'
mode.value = duplicate ? 'duplicate' : 'create' mode.value = duplicate ? 'duplicate' : 'create'
} }