nextcloud-calendar/src/mixins/EditorMixin.js

751 lines
20 KiB
JavaScript

/**
* @copyright Copyright (c) 2019 Georg Ehrke
*
* @author Georg Ehrke <oc.list@georgehrke.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { getRFCProperties } from '../models/rfcProps'
import logger from '../utils/logger.js'
import { getIllustrationForTitle } from '../utils/illustration.js'
import { getPrefixedRoute } from '../utils/router.js'
import { dateFactory } from '../utils/date.js'
import { uidToHexColor } from '../utils/color.js'
import {
mapGetters,
mapState,
} from 'vuex'
import { translate as t } from '@nextcloud/l10n'
/**
* This is a mixin for the editor. It contains common Vue stuff, that is
* required both in the popover as well as the sidebar.
*
* See inline for more documentation
*/
export default {
data() {
return {
// Indicator whether or not the event is currently loading
isLoading: true,
// Indicator whether or not loading the event failed
isError: false,
// Error message in case there was an error
error: null,
// The calendar-id of the selected calendar
calendarId: null,
// Whether or not an action is required on leave
requiresActionOnRouteLeave: true,
// Whether or not the this and all future option will be forced
// This is the case when editing the recurrence-rule of an existing recurring event
forceThisAndAllFuture: false,
// Whether or not the master item is being edited
isEditingMasterItem: false,
// Whether or not it is a recurrence-exception
isRecurrenceException: false,
}
},
computed: {
...mapGetters({
currentUserTimezone: 'getResolvedTimezone',
}),
...mapState({
initialCalendarsLoaded: (state) => state.calendars.initialCalendarsLoaded,
calendarObject: (state) => state.calendarObjectInstance.calendarObject,
calendarObjectInstance: (state) => state.calendarObjectInstance.calendarObjectInstance,
}),
eventComponent() {
return this.calendarObjectInstance?.eventComponent
},
/**
* Returns the events title or an empty string if the event is still loading
*
* @returns {string}
*/
title() {
return this.calendarObjectInstance?.title || ''
},
/**
* Returns the location or null if the event is still loading
*
* @returns {string|null}
*/
location() {
return this.calendarObjectInstance?.location || null
},
/**
* Returns the description or null if the event is still loading
*
* @returns {string|null}
*/
description() {
return this.calendarObjectInstance?.description || null
},
/**
* Returns the start-date (without timezone) or null if the event is still loading
*
* @returns {Date|null}
*/
startDate() {
return this.calendarObjectInstance?.startDate || null
},
/**
* Returns the timezone of the event's start-date or null if the event is still loading
*
* @returns {string|null}
*/
startTimezone() {
return this.calendarObjectInstance?.startTimezoneId || null
},
/**
* Returns the end-date (without timezone) or null if the event is still loading
*
* @returns {Date|null}
*/
endDate() {
return this.calendarObjectInstance?.endDate || null
},
/**
* Returns the timezone of the event's end-date or null if the event is still loading
*
* @returns {string|null}
*/
endTimezone() {
return this.calendarObjectInstance?.endTimezoneId || null
},
/**
* Returns whether or not the event is all-day or null if the event is still loading
*
* @returns {boolean}
*/
isAllDay() {
return this.calendarObjectInstance?.isAllDay || false
},
/**
* Returns whether or not the user is allowed to modify the all-day setting
*
* @returns {boolean}
*/
canModifyAllDay() {
return this.calendarObjectInstance?.canModifyAllDay || null
},
/**
* Returns an illustration matching this event's title
*
* @returns {string}
*/
backgroundImage() {
return getIllustrationForTitle(this.title)
},
/**
* Returns the color the illustration should be colored in
*
* @returns {String}
*/
illustrationColor() {
return this.color || this.selectedCalendarColor
},
/**
* Returns the color of the calendar selected by the user
* This is used to color illustration
*
* @returns {string|*}
*/
selectedCalendarColor() {
if (!this.selectedCalendar) {
const calendars = this.$store.getters.sortedCalendars
if (calendars.length > 0) {
return calendars[0].color
}
return uidToHexColor('')
}
return this.selectedCalendar.color
},
/**
* Returns the custom color of this event
*
* @returns {null|String}
*/
color() {
return this.calendarObjectInstance?.customColor || null
},
/**
* Returns whether or not to display save buttons
*
* @returns {boolean}
*/
showSaveButtons() {
return this.isReadOnly === false
},
/**
* Returns whether or not to allow editing the event
*
* @returns {boolean}
*/
isReadOnly() {
if (!this.calendarObject) {
return true
}
const calendar = this.$store.getters.getCalendarById(this.calendarObject.calendarId)
if (!calendar) {
return true
}
return calendar.readOnly
},
/**
* Returns all calendars selectable by the user
*
* @returns {Object[]}
*/
calendars() {
if (this.isReadOnly && this.calendarObject) {
return [
this.$store.getters.getCalendarById(this.calendarObject.calendarId),
]
}
return this.$store.getters.sortedCalendars
},
/**
* Returns the object of the selected calendar
*
* @returns {Object}
*/
selectedCalendar() {
return this.$store.getters.getCalendarById(this.calendarId)
},
/**
* Returns whether or not to display the calendar-picker
*
* @returns {boolean}
*/
showCalendarPicker() {
// Always show the calendar's name when we are in a read-only calendar
if (this.isReadOnly) {
return true
}
return this.$store.getters.sortedCalendars.length > 1
},
/**
* Returns whether or not the user is allowed to delete this event
*
* @returns {boolean}
*/
canDelete() {
if (!this.calendarObject) {
return false
}
if (this.isReadOnly) {
return false
}
if (this.isLoading) {
return false
}
return this.calendarObject.existsOnServer
},
/**
* Returns whether or not the user is allowed to create recurrence exceptions for this event
*
* @returns {boolean}
*/
canCreateRecurrenceException() {
if (!this.eventComponent) {
return false
}
return this.eventComponent.canCreateRecurrenceExceptions()
},
/**
* Returns a an object with properties from RFCs including
* their displayName, a description, options, etc.
*
* @returns {{geo, color, timeTransparency, description, resources, location, categories, accessClass, priority, status}}
*/
rfcProps() {
return getRFCProperties()
},
/**
* Returns whether or not this event can be downloaded from the server
*
* @returns {boolean}
*/
hasDownloadURL() {
if (!this.calendarObject) {
return false
}
if (this.isLoading) {
return false
}
return this.calendarObject.existsOnServer
},
/**
* Returns the download url as a string or null if event is loading or does not exist on the server (yet)
*
* @returns {string|null}
*/
downloadURL() {
if (!this.calendarObject) {
return null
}
if (!this.calendarObject.dav) {
return null
}
return this.calendarObject.dav.url + '?export'
},
/**
* Returns whether or not this is a new event
*
* @returns {boolean}
*/
isNew() {
if (!this.calendarObject) {
return true
}
if (!this.calendarObject.dav) {
return true
}
return false
},
},
methods: {
/**
* Changes the selected calendar
* Does not move the calendar-object yet, that's done in save
*
* @param {Object} selectedCalendar The new calendar selected by the user
*/
changeCalendar(selectedCalendar) {
this.calendarId = selectedCalendar.id
// If this is a new event that does not exist on the server yet,
// override the internally stored calendarId. If we did not do this,
// it would create the event in the default calendar first and move it
// to the desired calendar as a second step.
if (this.calendarObject && !this.calendarObject.existsOnServer) {
this.calendarObject.calendarId = selectedCalendar.id
}
},
/**
* This will force the user to update this and all future occurrences when saving
*/
forceModifyingFuture() {
this.forceThisAndAllFuture = true
},
/**
* Closes the editor and returns to normal calendar-view
*/
closeEditor() {
const params = Object.assign({}, this.$store.state.route.params)
delete params.object
delete params.recurrenceId
this.$router.push({
name: getPrefixedRoute(this.$store.state.route.name, 'CalendarView'),
params,
})
this.$store.commit('resetCalendarObjectInstanceObjectIdAndRecurrenceId')
},
/**
* Resets the calendar-object back to it's original state and closes the editor
*/
async cancel() {
if (this.isLoading) {
return
}
if (!this.calendarObject) {
logger.error('Calendar-object not found')
this.closeEditor()
return
}
this.$store.commit('resetCalendarObjectToDav', {
calendarObject: this.calendarObject,
})
this.requiresActionOnRouteLeave = false
this.closeEditor()
},
/**
* Saves a calendar-object
*
* @param {Boolean} thisAndAllFuture Whether to modify only this or this and all future occurrences
* @returns {Promise<void>}
*/
async save(thisAndAllFuture = false) {
if (!this.calendarObject) {
logger.error('Calendar-object not found')
return
}
if (this.isReadOnly) {
return
}
if (this.forceThisAndAllFuture) {
thisAndAllFuture = true
}
this.isLoading = true
await this.$store.dispatch('saveCalendarObjectInstance', {
thisAndAllFuture,
calendarId: this.calendarId,
})
this.isLoading = false
},
/**
* Saves a calendar-object and closes the editor
*
* @param {Boolean} thisAndAllFuture Whether to modify only this or this and all future occurrences
* @returns {Promise<void>}
*/
async saveAndLeave(thisAndAllFuture = false) {
await this.save(thisAndAllFuture)
this.requiresActionOnRouteLeave = false
this.closeEditor()
},
/**
* Deletes a calendar-object
*
* @param {Boolean} thisAndAllFuture Whether to delete only this or this and all future occurrences
* @returns {Promise<void>}
*/
async delete(thisAndAllFuture = false) {
if (!this.calendarObject) {
logger.error('Calendar-object not found')
return
}
if (this.isReadOnly) {
return
}
this.isLoading = true
await this.$store.dispatch('deleteCalendarObjectInstance', { thisAndAllFuture })
this.isLoading = false
},
/**
* Deletes a calendar-object and closes the editor
*
* @param {Boolean} thisAndAllFuture Whether to delete only this or this and all future occurrences
* @returns {Promise<void>}
*/
async deleteAndLeave(thisAndAllFuture = false) {
await this.delete(thisAndAllFuture)
this.requiresActionOnRouteLeave = false
this.closeEditor()
},
/**
* Updates the title of this event
*
* @param {String} title New title
*/
updateTitle(title) {
if (title.trim() === '') {
title = null
}
this.$store.commit('changeTitle', {
calendarObjectInstance: this.calendarObjectInstance,
title,
})
},
/**
* Updates the description of this event
*
* @param {String} description New description
*/
updateDescription(description) {
this.$store.commit('changeDescription', {
calendarObjectInstance: this.calendarObjectInstance,
description,
})
},
/**
* Updates the location of this event
*
* @param {String} location New location
*/
updateLocation(location) {
this.$store.commit('changeLocation', {
calendarObjectInstance: this.calendarObjectInstance,
location,
})
},
/**
* Updates the start date of this event
*
* @param {Date} startDate New start date
*/
updateStartDate(startDate) {
this.$store.dispatch('changeStartDate', {
calendarObjectInstance: this.calendarObjectInstance,
startDate,
})
},
/**
* Updates the timezone of this event's start date
*
* @param {String} startTimezone New start timezone
*/
updateStartTimezone(startTimezone) {
if (!startTimezone) {
return
}
this.$store.dispatch('changeStartTimezone', {
calendarObjectInstance: this.calendarObjectInstance,
startTimezone,
})
},
/**
* Updates the end date of this event
*
* @param {Date} endDate New end date
*/
updateEndDate(endDate) {
this.$store.commit('changeEndDate', {
calendarObjectInstance: this.calendarObjectInstance,
endDate,
})
},
/**
* Updates the timezone of this event's end date
*
* @param {String} endTimezone New end timezone
*/
updateEndTimezone(endTimezone) {
if (!endTimezone) {
return
}
this.$store.dispatch('changeEndTimezone', {
calendarObjectInstance: this.calendarObjectInstance,
endTimezone,
})
},
/**
* Toggles the event between all-day and timed
*/
toggleAllDay() {
this.$store.dispatch('toggleAllDay', {
calendarObjectInstance: this.calendarObjectInstance,
})
},
/**
* Resets the internal state after changing the viewed calendar-object
*/
resetState() {
this.isLoading = true
this.isError = false
this.error = null
this.calendarId = null
this.requiresActionOnRouteLeave = true
this.forceThisAndAllFuture = false
this.isEditingMasterItem = false
this.isRecurrenceException = false
},
/**
* This function returns a promise that resolves
* once the calendars were fetched from the server
*
* @returns {Promise<void>}
*/
loadingCalendars() {
if (this.initialCalendarsLoaded) {
return Promise.resolve()
}
return new Promise((resolve) => {
const watcher = this.$watch('initialCalendarsLoaded', () => {
resolve()
watcher()
})
})
},
},
/**
* This is executed before entering the Editor routes
*
* @param {Object} to The route to navigate to
* @param {Object} from The route coming from
* @param {Function} next Function to be called when ready to load the next view
*/
async beforeRouteEnter(to, from, next) {
if (to.name === 'NewSidebarView' || to.name === 'NewPopoverView') {
next(async vm => {
vm.resetState()
const isAllDay = (to.params.allDay === '1')
const start = parseInt(to.params.dtstart, 10)
const end = parseInt(to.params.dtend, 10)
const timezoneId = vm.$store.getters.getResolvedTimezone
try {
await vm.loadingCalendars()
await vm.$store.dispatch('getCalendarObjectInstanceForNewEvent', { isAllDay, start, end, timezoneId })
vm.calendarId = vm.calendarObject.calendarId
} catch (error) {
console.debug(error)
vm.isError = true
vm.error = t('calendar', 'It might have been deleted, or there was a typo in a link')
} finally {
vm.isLoading = false
}
})
} else {
next(async vm => {
vm.resetState()
const objectId = to.params.object
const recurrenceId = to.params.recurrenceId
if (recurrenceId === 'next') {
const closeToDate = dateFactory()
// TODO: can we replace this by simply returning the new route since we are inside next()
// Probably not though, because it's async
await vm.loadingCalendars()
const recurrenceId = await vm.$store.dispatch('resolveClosestRecurrenceIdForCalendarObject', { objectId, closeToDate })
const params = Object.assign({}, vm.$route.params, { recurrenceId })
vm.$router.replace({ name: vm.$route.name, params })
}
try {
await vm.loadingCalendars()
await vm.$store.dispatch('getCalendarObjectInstanceByObjectIdAndRecurrenceId', { objectId, recurrenceId })
vm.calendarId = vm.calendarObject.calendarId
vm.isEditingMasterItem = vm.eventComponent.isMasterItem()
vm.isRecurrenceException = vm.eventComponent.isRecurrenceException()
} catch (error) {
console.debug(error)
vm.isError = true
vm.error = t('calendar', 'It might have been deleted, or there was a typo in a link')
} finally {
vm.isLoading = false
}
})
}
},
/**
* This function is called when the route changes. This can be caused by various actions:
* - Change of selected time-range when creating new event
* - Navigating through the calendar-view
*
* @param {Object} to The route to navigate to
* @param {Object} from The route coming from
* @param {Function} next Function to be called when ready to load the next view
*/
async beforeRouteUpdate(to, from, next) {
// If we are in the New Event dialog, we want to update the selected time
if (to.name === 'NewSidebarView' || to.name === 'NewPopoverView') {
// If allDay, dtstart and dtend are the same there is no need to update.
// This is usally the case when navigating through the calendar while the editor is open
if (to.params.allDay === from.params.allDay
&& to.params.dtstart === from.params.dtstart
&& to.params.dtend === from.params.dtend) {
next()
return
}
const isAllDay = (to.params.allDay === '1')
const start = to.params.dtstart
const end = to.params.dtend
const timezoneId = this.$store.getters.getResolvedTimezone
await this.loadingCalendars()
await this.$store.dispatch('updateCalendarObjectInstanceForNewEvent', { isAllDay, start, end, timezoneId })
next()
} else {
// If both the objectId and recurrenceId remained the same
// there is no need to update. This is usally the case when navigating
// through the calendar while the editor is open
if (to.params.object === from.params.object
&& to.params.recurrenceId === from.params.recurrenceId) {
next()
return
}
this.isLoading = true
try {
await this.save()
} catch (error) {
console.debug(error)
next(false)
return
}
this.resetState()
const objectId = to.params.object
const recurrenceId = to.params.recurrenceId
if (recurrenceId === 'next') {
const closeToDate = dateFactory()
await this.loadingCalendars()
const recurrenceId = await this.$store.dispatch('resolveClosestRecurrenceIdForCalendarObject', { objectId, closeToDate })
const params = Object.assign({}, this.$route.params, { recurrenceId })
next({ name: this.$route.name, params })
return
}
try {
await this.loadingCalendars()
await this.$store.dispatch('getCalendarObjectInstanceByObjectIdAndRecurrenceId', { objectId, recurrenceId })
this.calendarId = this.calendarObject.calendarId
this.isEditingMasterItem = this.eventComponent.isMasterItem()
this.isRecurrenceException = this.eventComponent.isRecurrenceException()
} catch (error) {
console.debug(error)
this.isError = true
this.error = t('calendar', 'It might have been deleted, or there was a typo in the link')
} finally {
this.isLoading = false
next()
}
}
},
/**
* This route is called when the user leaves the editor
*
* @param {Object} to The route to navigate to
* @param {Object} from The route coming from
* @param {Function} next Function to be called when ready to load the next view
*/
async beforeRouteLeave(to, from, next) {
// requiresActionOnRouteLeave is false when an action like deleting / saving / cancelling was already taken.
// The responsibility of this method is to automatically save the event when the user clicks outside the editor
if (!this.requiresActionOnRouteLeave) {
next()
return
}
try {
await this.save()
next()
} catch (error) {
console.debug(error)
next(false)
}
},
}