fix: refactor event handling and validation logic in CalendarEventPanel and EventModal components

This commit is contained in:
Shariq Ansari 2025-09-02 21:07:10 +05:30
parent 8031964d3d
commit 1e99192448
3 changed files with 165 additions and 208 deletions

View File

@ -351,9 +351,14 @@ import Link from '@/components/Controls/Link.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import DescriptionIcon from '@/components/Icons/DescriptionIcon.vue'
import { globalStore } from '@/stores/global'
import { usersStore } from '@/stores/users'
import { validateEmail } from '@/utils'
import { allTimeSlots } from '@/components/Calendar/utils'
import {
normalizeParticipants,
buildEndTimeOptions,
computeAutoToTime,
validateTimeRange,
parseEventDoc,
} from '@/composables/event'
import {
TextInput,
Switch,
@ -389,7 +394,6 @@ const emit = defineEmits([
const router = useRouter()
const { $dialog } = globalStore()
const { getUser } = usersStore()
const show = defineModel()
const event = defineModel('event')
@ -401,18 +405,7 @@ const peoples = computed({
return _event.value.event_participants || []
},
set(list) {
const seen = new Set()
const out = []
for (const a of list || []) {
if (!a?.email || seen.has(a.email)) continue
seen.add(a.email)
out.push({
email: a.email,
reference_doctype: a.reference_doctype || 'Contact',
reference_docname: a.reference_docname || '',
})
}
_event.value.event_participants = out
_event.value.event_participants = normalizeParticipants(list)
sync()
},
})
@ -461,12 +454,12 @@ function fetchEvent() {
name: event.value.id,
fields: ['*'],
onSuccess: (data) => {
_event.value = parseEvent(data)
_event.value = parseEventDoc(data)
oldEvent.value = { ..._event.value }
},
})
if (eventResource.value.doc && !event.value.reloadEvent) {
_event.value = parseEvent(eventResource.value.doc)
_event.value = parseEventDoc(eventResource.value.doc)
oldEvent.value = { ..._event.value }
} else {
eventResource.value.reload()
@ -478,30 +471,6 @@ function fetchEvent() {
showAllParticipants.value = false
}
function parseEvent(_e) {
return {
id: _e.name,
title: _e.subject,
description: _e.description,
status: _e.status,
fromDate: dayjs(_e.starts_on).format('YYYY-MM-DD'),
toDate: dayjs(_e.ends_on).format('YYYY-MM-DD'),
fromTime: dayjs(_e.starts_on).format('HH:mm'),
toTime: dayjs(_e.ends_on).format('HH:mm'),
isFullDay: _e.all_day,
eventType: _e.event_type,
color: _e.color,
referenceDoctype: _e.reference_doctype,
referenceDocname: _e.reference_docname,
event_participants: _e.event_participants || [],
owner: {
label: getUser(_e.owner).full_name,
image: getUser(_e.owner).user_image,
value: _e.owner,
},
}
}
function focusOnTitle() {
setTimeout(() => {
if (['edit', 'new', 'duplicate'].includes(props.mode)) {
@ -523,63 +492,27 @@ function updateDate(d) {
function updateTime(t, fromTime = false) {
error.value = null
const prevTo = _event.value.toTime
if (fromTime) {
_event.value.fromTime = t
const hour = parseInt(t.split(':')[0])
const minute = parseInt(t.split(':')[1])
const computePlusHour = () => {
let nh = hour + 1
let nm = minute
if (nh >= 24) {
nh = 23
nm = 59
}
return `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}`
}
if (!_event.value.toTime) {
_event.value.toTime = computePlusHour()
} else if (_event.value.toTime <= t) {
_event.value.toTime = computePlusHour()
if (!_event.value.toTime || _event.value.toTime <= t) {
_event.value.toTime = computeAutoToTime(t)
}
} else {
_event.value.toTime = t
}
validateFromToTime() && sync()
}
function validateFromToTime() {
// Generic validator for start/end times before saving.
// Returns true if valid, else sets error message and returns false.
error.value = null
// Full day events don't require time validation
if (_event.value.isFullDay) return true
// Only validate within the single start date; ignore any separate end date.
const fromDate = _event.value.fromDate
const fromTime = _event.value.fromTime
const toTime = _event.value.toTime
if (!fromTime || !toTime) {
error.value = __('Start and end time are required')
return false
const { valid, error: err } = validateTimeRange({
fromDate: _event.value.fromDate,
fromTime: _event.value.fromTime,
toTime: _event.value.toTime,
isFullDay: _event.value.isFullDay,
})
if (!valid) {
error.value = err
_event.value.toTime = prevTo
} else {
sync()
}
const start = dayjs(fromDate + ' ' + fromTime)
const end = dayjs(fromDate + ' ' + toTime)
if (!start.isValid() || !end.isValid()) {
error.value = __('Invalid start or end time')
return false
}
if (end.diff(start, 'minute') <= 0) {
error.value = __('End time should be after start time')
return false
}
return true
}
function saveEvent() {
@ -590,7 +523,16 @@ function saveEvent() {
return
}
if (!validateFromToTime()) return
const { valid, error: err } = validateTimeRange({
fromDate: _event.value.fromDate,
fromTime: _event.value.fromTime,
toTime: _event.value.toTime,
isFullDay: _event.value.isFullDay,
})
if (!valid) {
error.value = err
return
}
oldEvent.value = { ..._event.value }
emit('save', _event.value)
@ -716,42 +658,7 @@ function getTooltip(m) {
return parts.length ? parts.join(': ') : email
}
function formatDuration(mins) {
// For < 1 hour show minutes, else show hours (with decimal for 15/30/45 mins)
if (mins < 60) return __('{0} mins', [mins])
let hours = mins / 60
// keep hours decimal to 2 only if decimal is not 0
if (hours % 1 !== 0 && hours % 1 !== 0.5) {
hours = hours.toFixed(2)
}
if (Number.isInteger(hours)) {
return hours === 1 ? __('1 hr') : __('{0} hrs', [hours])
}
// Keep decimal representation for > 1 hour fractional durations
return `${hours} hrs`
}
const toOptions = computed(() => {
const fromTime = _event.value.fromTime
const timeSlots = allTimeSlots()
if (!fromTime) return timeSlots
const [fh, fm] = fromTime.split(':').map((n) => parseInt(n))
const fromTotal = fh * 60 + fm
// find first slot strictly after fromTime (even if fromTime not exactly a slot)
const startIndex = timeSlots.findIndex((o) => o.value > fromTime)
if (startIndex === -1) return []
return timeSlots.slice(startIndex).map((o) => {
const [th, tm] = o.value.split(':').map((n) => parseInt(n))
const toTotal = th * 60 + tm
const duration = toTotal - fromTotal
return {
...o,
label: `${o.label} (${formatDuration(duration)})`,
}
})
})
const toOptions = computed(() => buildEndTimeOptions(_event.value.fromTime))
function updateEvent(_e) {
Object.assign(_event.value, _e)

View File

@ -215,8 +215,13 @@ import {
} from 'frappe-ui'
import { globalStore } from '@/stores/global'
import { validateEmail } from '@/utils'
import { allTimeSlots } from '@/components/Calendar/utils'
import { useEvent } from '@/composables/event'
import {
useEvent,
normalizeParticipants,
buildEndTimeOptions,
computeAutoToTime,
validateTimeRange,
} from '@/composables/event'
import { CalendarColorMap as colorMap } from 'frappe-ui'
import { onMounted, ref, computed, h } from 'vue'
@ -271,18 +276,7 @@ const peoples = computed({
return _event.value.event_participants || []
},
set(list) {
const seen = new Set()
const out = []
for (const a of list || []) {
if (!a?.email || seen.has(a.email)) continue
seen.add(a.email)
out.push({
email: a.email,
reference_doctype: a.reference_doctype || 'Contact',
reference_docname: a.reference_docname || '',
})
}
_event.value.event_participants = out
_event.value.event_participants = normalizeParticipants(list)
},
})
@ -323,42 +317,25 @@ function updateDate(d) {
function updateTime(t, fromTime = false) {
error.value = null
let oldTo = _event.value.toTime || _event.value.fromTime
const prevTo = _event.value.toTime
if (fromTime) {
_event.value.fromTime = t
if (!_event.value.toTime) {
const hour = parseInt(t.split(':')[0])
const minute = parseInt(t.split(':')[1])
let nh = hour + 1
let nm = minute
if (nh >= 24) {
nh = 23
nm = 59
}
_event.value.toTime = `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}`
if (!_event.value.toTime || _event.value.toTime <= t) {
_event.value.toTime = computeAutoToTime(t)
}
} else {
_event.value.toTime = t
}
validateFromToTime(oldTo)
}
function validateFromToTime(oldTo) {
if (_event.value.isFullDay) return true
if (_event.value.toTime && _event.value.fromTime) {
const diff = dayjs(_event.value.fromDate + ' ' + _event.value.toTime).diff(
dayjs(_event.value.fromDate + ' ' + _event.value.fromTime),
'minute',
)
if (diff <= 0) {
_event.value.toTime = oldTo
error.value = __('End time should be after start time')
return false
}
const { valid, error: err } = validateTimeRange({
fromDate: _event.value.fromDate,
fromTime: _event.value.fromTime,
toTime: _event.value.toTime,
isFullDay: _event.value.isFullDay,
})
if (!valid) {
error.value = err
_event.value.toTime = prevTo
}
return true
}
function update() {
@ -369,7 +346,16 @@ function update() {
return
}
validateFromToTime(_event.value.toTime)
const { valid, error: err } = validateTimeRange({
fromDate: _event.value.fromDate,
fromTime: _event.value.fromTime,
toTime: _event.value.toTime,
isFullDay: _event.value.isFullDay,
})
if (!valid) {
error.value = err
return
}
if (_event.value.id && _event.value.id !== 'duplicate') {
updateEvent()
@ -467,42 +453,7 @@ function deleteEvent() {
})
}
function formatDuration(mins) {
// For < 1 hour show minutes, else show hours (with decimal for 15/30/45 mins)
if (mins < 60) return __('{0} mins', [mins])
let hours = mins / 60
// keep hours decimal to 2 only if decimal is not 0
if (hours % 1 !== 0 && hours % 1 !== 0.5) {
hours = hours.toFixed(2)
}
if (Number.isInteger(hours)) {
return hours === 1 ? __('1 hr') : __('{0} hrs', [hours])
}
// Keep decimal representation for > 1 hour fractional durations
return `${hours} hrs`
}
const toOptions = computed(() => {
const fromTime = _event.value.fromTime
const timeSlots = allTimeSlots()
if (!fromTime) return timeSlots
const [fh, fm] = fromTime.split(':').map((n) => parseInt(n))
const fromTotal = fh * 60 + fm
// find first slot strictly after fromTime (even if fromTime not exactly a slot)
const startIndex = timeSlots.findIndex((o) => o.value > fromTime)
if (startIndex === -1) return []
return timeSlots.slice(startIndex).map((o) => {
const [th, tm] = o.value.split(':').map((n) => parseInt(n))
const toTotal = th * 60 + tm
const duration = toTotal - fromTotal
return {
...o,
label: `${o.label} (${formatDuration(duration)})`,
}
})
})
const toOptions = computed(() => buildEndTimeOptions(_event.value.fromTime))
const linkDoctypeOptions = [
{ label: '', value: '' },

View File

@ -2,6 +2,7 @@ import { usersStore } from '@/stores/users'
import { dayjs, createListResource } from 'frappe-ui'
import { sameArrayContents } from '@/utils'
import { computed, ref } from 'vue'
import { allTimeSlots } from '@/components/Calendar/utils'
export const showEventModal = ref(false)
export const activeEvent = ref(null)
@ -136,3 +137,101 @@ export function useEvent(doctype, docname) {
startDate,
}
}
export function normalizeParticipants(list = []) {
const seen = new Set()
const out = []
for (const a of list || []) {
if (!a?.email || seen.has(a.email)) continue
seen.add(a.email)
out.push({
email: a.email,
reference_doctype: a.reference_doctype || 'Contact',
reference_docname: a.reference_docname || '',
})
}
return out
}
export function formatDuration(mins) {
if (mins < 60) return __('{0} mins', [mins])
let hours = mins / 60
if (hours % 1 !== 0 && hours % 1 !== 0.5) {
hours = hours.toFixed(2)
}
if (Number.isInteger(hours)) {
return hours === 1 ? __('1 hr') : __('{0} hrs', [hours])
}
return `${hours} hrs`
}
export function buildEndTimeOptions(fromTime) {
const timeSlots = allTimeSlots()
if (!fromTime) return timeSlots
const startIndex = timeSlots.findIndex((o) => o.value > fromTime)
if (startIndex === -1) return []
const [fh, fm] = fromTime.split(':').map((n) => parseInt(n))
const fromTotal = fh * 60 + fm
return timeSlots.slice(startIndex).map((o) => {
const [th, tm] = o.value.split(':').map((n) => parseInt(n))
const toTotal = th * 60 + tm
const duration = toTotal - fromTotal
return { ...o, label: `${o.label} (${formatDuration(duration)})` }
})
}
export function computeAutoToTime(fromTime) {
if (!fromTime) return ''
const [hour, minute] = fromTime.split(':').map((n) => parseInt(n))
let nh = hour + 1
let nm = minute
if (nh >= 24) {
nh = 23
nm = 59
}
return `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}`
}
export function validateTimeRange({ fromDate, fromTime, toTime, isFullDay }) {
if (isFullDay) return { valid: true, error: null }
if (!fromTime || !toTime) {
return { valid: false, error: __('Start and end time are required') }
}
const start = dayjs(fromDate + ' ' + fromTime)
const end = dayjs(fromDate + ' ' + toTime)
if (!start.isValid() || !end.isValid()) {
return { valid: false, error: __('Invalid start or end time') }
}
if (end.diff(start, 'minute') <= 0) {
return { valid: false, error: __('End time should be after start time') }
}
return { valid: true, error: null }
}
export function parseEventDoc(doc) {
if (!doc) return {}
const { getUser } = usersStore()
return {
id: doc.name,
title: doc.subject,
description: doc.description,
status: doc.status,
fromDate: dayjs(doc.starts_on).format('YYYY-MM-DD'),
toDate: dayjs(doc.ends_on).format('YYYY-MM-DD'),
fromTime: dayjs(doc.starts_on).format('HH:mm'),
toTime: dayjs(doc.ends_on).format('HH:mm'),
isFullDay: doc.all_day,
eventType: doc.event_type,
color: doc.color,
referenceDoctype: doc.reference_doctype,
referenceDocname: doc.reference_docname,
event_participants: doc.event_participants || [],
owner: doc.owner
? {
label: getUser(doc.owner).full_name,
image: getUser(doc.owner).user_image,
value: doc.owner,
}
: null,
}
}