calendar/src/views/EditSidebar.vue

724 lines
22 KiB
Vue

<!--
- @copyright Copyright (c) 2019 Georg Ehrke <oc.list@georgehrke.com>
- @copyright Copyright (c) 2019 Jakob Röhrl <jakob.roehrl@web.de>
- @copyright Copyright (c) 2022 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/
-
- @author Georg Ehrke <oc.list@georgehrke.com>
- @author Jakob Röhrl <jakob.roehrl@web.de>
- @author Richard Steinmetz <richard@steinmetz.cloud>
-
- @license AGPL-3.0-or-later
-
- 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/>.
-
-->
<template>
<NcAppSidebar :empty="isLoading || isError"
:force-menu="true"
@close="cancel">
<template v-if="isLoading">
<div class="app-sidebar__loading-indicator">
<div class="icon icon-loading app-sidebar-tab-loading-indicator__icon" />
</div>
</template>
<template v-else-if="isError">
<NcEmptyContent :name="$t('calendar', 'Event does not exist')" :description="error">
<template #icon>
<CalendarBlank :size="20" decorative />
</template>
</NcEmptyContent>
</template>
<template v-if="!isLoading && !isError && !isNew"
#secondary-actions>
<NcActionLink v-if="!hideEventExport && hasDownloadURL"
:href="downloadURL">
<template #icon>
<Download :size="20" decorative />
</template>
{{ $t('calendar', 'Export') }}
</NcActionLink>
<NcActionButton v-if="!canCreateRecurrenceException && !isReadOnly" @click="duplicateEvent()">
<template #icon>
<ContentDuplicate :size="20" decorative />
</template>
{{ $t('calendar', 'Duplicate') }}
</NcActionButton>
<NcActionButton v-if="canDelete && !canCreateRecurrenceException" @click="deleteAndLeave(false)">
<template #icon>
<Delete :size="20" decorative />
</template>
{{ $t('calendar', 'Delete') }}
</NcActionButton>
<NcActionButton v-if="canDelete && canCreateRecurrenceException" @click="deleteAndLeave(false)">
<template #icon>
<Delete :size="20" decorative />
</template>
{{ $t('calendar', 'Delete this occurrence') }}
</NcActionButton>
<NcActionButton v-if="canDelete && canCreateRecurrenceException" @click="deleteAndLeave(true)">
<template #icon>
<Delete :size="20" decorative />
</template>
{{ $t('calendar', 'Delete this and all future') }}
</NcActionButton>
</template>
<template v-if="!isLoading && !isError"
#description>
<CalendarPickerHeader :value="selectedCalendar"
:calendars="calendars"
:is-read-only="isReadOnly || !canModifyCalendar"
@update:value="changeCalendar" />
<PropertyTitle :value="title"
:is-read-only="isReadOnly"
@update:value="updateTitle" />
<PropertyTitleTimePicker :start-date="startDate"
:start-timezone="startTimezone"
:end-date="endDate"
:end-timezone="endTimezone"
:is-all-day="isAllDay"
:is-read-only="isReadOnly"
:can-modify-all-day="canModifyAllDay"
:user-timezone="currentUserTimezone"
:append-to-body="true"
@update-start-date="updateStartDate"
@update-start-timezone="updateStartTimezone"
@update-end-date="updateEndDate"
@update-end-timezone="updateEndTimezone"
@toggle-all-day="toggleAllDay" />
<PropertyText class="property-location"
:is-read-only="isReadOnly"
:prop-model="rfcProps.location"
:value="location"
:linkify-links="true"
@update:value="updateLocation" />
<PropertyText class="property-description"
:is-read-only="isReadOnly"
:prop-model="rfcProps.description"
:value="description"
:linkify-links="true"
@update:value="updateDescription" />
<InvitationResponseButtons v-if="isViewedByAttendee"
:attendee="userAsAttendee"
:calendar-id="calendarId"
:narrow="true"
:grow-horizontally="true"
@close="closeEditorAndSkipAction" />
</template>
<NcAppSidebarTab v-if="!isLoading && !isError"
id="app-sidebar-tab-details"
class="app-sidebar-tab"
:name="$t('calendar', 'Details')"
:order="0">
<template #icon>
<InformationOutline :size="20" decorative />
</template>
<div class="app-sidebar-tab__content">
<PropertySelect :is-read-only="isReadOnly"
:prop-model="rfcProps.status"
:value="status"
@update:value="updateStatus" />
<PropertySelect :is-read-only="isReadOnly"
:prop-model="rfcProps.accessClass"
:value="accessClass"
@update:value="updateAccessClass" />
<PropertySelect :is-read-only="isReadOnly"
:prop-model="rfcProps.timeTransparency"
:value="timeTransparency"
@update:value="updateTimeTransparency" />
<PropertySelectMultiple :colored-options="true"
:is-read-only="isReadOnly"
:prop-model="rfcProps.categories"
:value="categories"
@add-single-value="addCategory"
@remove-single-value="removeCategory" />
<PropertyColor :calendar-color="selectedCalendarColor"
:is-read-only="isReadOnly"
:prop-model="rfcProps.color"
:value="color"
@update:value="updateColor" />
<AlarmList :calendar-object-instance="calendarObjectInstance"
:is-read-only="isReadOnly" />
<!-- TODO: If not editing the master item, force updating this and all future -->
<!-- TODO: You can't edit recurrence-rule of no-range recurrence-exception -->
<Repeat :calendar-object-instance="calendarObjectInstance"
:recurrence-rule="calendarObjectInstance.recurrenceRule"
:is-read-only="isReadOnly"
:is-editing-master-item="isEditingMasterItem"
:is-recurrence-exception="isRecurrenceException"
@force-this-and-all-future="forceModifyingFuture" />
<AttachmentsList v-if="!isLoading"
:calendar-object-instance="calendarObjectInstance"
:is-read-only="isReadOnly" />
<NcModal v-if="showModal && !isPrivate()"
:name="t('calendar', 'Managing shared access')"
@close="closeAttachmentsModal">
<div class="modal-content">
<div v-if="showPreloader" class="modal-content-preloader">
<div :style="`width:${sharedProgress}%`" />
</div>
<div class="modal-h">
{{ n('calendar', 'User requires access to your file', 'Users require access to your file', showModalUsers.length) }}
</div>
<div class="users">
<NcListItemIcon v-for="attendee in showModalUsers"
:key="attendee.uri"
class="user-list-item"
:name="attendee.commonName"
:subtitle="emailWithoutMailto(attendee.uri)"
:is-no-user="true" />
</div>
<div class="modal-subtitle">
{{ n('calendar', 'Attachment requires shared access', 'Attachments requiring shared access', showModalNewAttachments.length) }}
</div>
<div class="attachments">
<NcListItemIcon v-for="attachment in showModalNewAttachments"
:key="attachment.xNcFileId"
class="attachment-list-item"
:name="getBaseName(attachment.fileName)"
:url="getPreview(attachment)"
:force-display-actions="false" />
</div>
<div class="modal-footer">
<div class="modal-footer-checkbox">
<NcCheckboxRadioSwitch v-if="!isPrivate()" :checked.sync="doNotShare">
{{ t('calendar', 'Deny access') }}
</NcCheckboxRadioSwitch>
</div>
<div class="modal-footer-buttons">
<NcButton @click="closeAttachmentsModal">
{{ t('calendar', 'Cancel') }}
</NcButton>
<NcButton type="primary"
:disabled="showPreloader"
@click="acceptAttachmentsModal(thisAndAllFuture)">
{{ t('calendar', 'Invite') }}
</NcButton>
</div>
</div>
</div>
</NcModal>
</div>
<SaveButtons v-if="showSaveButtons"
class="app-sidebar-tab__buttons"
:can-create-recurrence-exception="canCreateRecurrenceException"
:is-new="isNew"
:force-this-and-all-future="forceThisAndAllFuture"
@save-this-only="prepareAccessForAttachments(false)"
@save-this-and-all-future="prepareAccessForAttachments(true)" />
</NcAppSidebarTab>
<NcAppSidebarTab v-if="!isLoading && !isError"
id="app-sidebar-tab-attendees"
class="app-sidebar-tab"
:name="$t('calendar', 'Attendees')"
:order="1">
<template #icon>
<AccountMultiple :size="20" decorative />
</template>
<div class="app-sidebar-tab__content">
<InviteesList v-if="!isLoading"
:calendar-object-instance="calendarObjectInstance"
:is-read-only="isReadOnly"
:is-shared-with-me="isSharedWithMe"
@update-dates="updateDates" />
</div>
<SaveButtons v-if="showSaveButtons"
class="app-sidebar-tab__buttons"
:can-create-recurrence-exception="canCreateRecurrenceException"
:is-new="isNew"
:force-this-and-all-future="forceThisAndAllFuture"
@save-this-only="prepareAccessForAttachments(false)"
@save-this-and-all-future="prepareAccessForAttachments(true)" />
</NcAppSidebarTab>
<NcAppSidebarTab v-if="!isLoading && !isError && showResources"
id="app-sidebar-tab-resources"
class="app-sidebar-tab"
:name="$t('calendar', 'Resources')"
:order="3">
<template #icon>
<MapMarker :size="20" decorative />
</template>
<div class="app-sidebar-tab__content">
<ResourceList v-if="!isLoading"
:calendar-object-instance="calendarObjectInstance"
:is-read-only="isReadOnly" />
</div>
<SaveButtons v-if="showSaveButtons"
class="app-sidebar-tab__buttons"
:can-create-recurrence-exception="canCreateRecurrenceException"
:is-new="isNew"
:force-this-and-all-future="forceThisAndAllFuture"
@save-this-only="prepareAccessForAttachments(false)"
@save-this-and-all-future="prepareAccessForAttachments(true)" />
</NcAppSidebarTab>
</NcAppSidebar>
</template>
<script>
import {
NcAppSidebar,
NcAppSidebarTab,
NcActionLink,
NcActionButton,
NcEmptyContent,
NcModal,
NcListItemIcon,
NcButton,
NcCheckboxRadioSwitch,
} from '@nextcloud/vue'
import { mapState } from 'vuex'
import { generateUrl } from '@nextcloud/router'
import AlarmList from '../components/Editor/Alarm/AlarmList.vue'
import InviteesList from '../components/Editor/Invitees/InviteesList.vue'
import PropertySelect from '../components/Editor/Properties/PropertySelect.vue'
import PropertyText from '../components/Editor/Properties/PropertyText.vue'
import PropertyTitleTimePicker from '../components/Editor/Properties/PropertyTitleTimePicker.vue'
import PropertyTitle from '../components/Editor/Properties/PropertyTitle.vue'
import Repeat from '../components/Editor/Repeat/Repeat.vue'
import EditorMixin from '../mixins/EditorMixin.js'
import moment from '@nextcloud/moment'
import SaveButtons from '../components/Editor/SaveButtons.vue'
import PropertySelectMultiple from '../components/Editor/Properties/PropertySelectMultiple.vue'
import PropertyColor from '../components/Editor/Properties/PropertyColor.vue'
import ResourceList from '../components/Editor/Resources/ResourceList.vue'
import InvitationResponseButtons from '../components/Editor/InvitationResponseButtons.vue'
import AttachmentsList from '../components/Editor/Attachments/AttachmentsList.vue'
import CalendarPickerHeader from '../components/Editor/CalendarPickerHeader.vue'
import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'
import CalendarBlank from 'vue-material-design-icons/CalendarBlank.vue'
import Delete from 'vue-material-design-icons/Delete.vue'
import Download from 'vue-material-design-icons/Download.vue'
import ContentDuplicate from 'vue-material-design-icons/ContentDuplicate.vue'
import InformationOutline from 'vue-material-design-icons/InformationOutline.vue'
import MapMarker from 'vue-material-design-icons/MapMarker.vue'
import { shareFile } from '../services/attachmentService.js'
import { Parameter } from '@nextcloud/calendar-js'
import getTimezoneManager from '../services/timezoneDataProviderService.js'
import logger from '../utils/logger.js'
export default {
name: 'EditSidebar',
components: {
ResourceList,
PropertyColor,
PropertySelectMultiple,
SaveButtons,
AlarmList,
NcAppSidebar,
NcAppSidebarTab,
NcActionLink,
NcActionButton,
NcEmptyContent,
NcModal,
NcListItemIcon,
NcButton,
NcCheckboxRadioSwitch,
InviteesList,
PropertySelect,
PropertyText,
PropertyTitleTimePicker,
Repeat,
AccountMultiple,
CalendarBlank,
Delete,
Download,
ContentDuplicate,
InformationOutline,
MapMarker,
InvitationResponseButtons,
AttachmentsList,
CalendarPickerHeader,
PropertyTitle,
},
mixins: [
EditorMixin,
],
data() {
return {
thisAndAllFuture: false,
doNotShare: false,
showModal: false,
showModalNewAttachments: [],
showModalUsers: [],
sharedProgress: 0,
showPreloader: false,
}
},
computed: {
...mapState({
locale: (state) => state.settings.momentLocale,
hideEventExport: (state) => state.settings.hideEventExport,
attachmentsFolder: state => state.settings.attachmentsFolder,
showResources: state => state.settings.showResources,
}),
accessClass() {
return this.calendarObjectInstance?.accessClass || null
},
categories() {
return this.calendarObjectInstance?.categories || null
},
status() {
return this.calendarObjectInstance?.status || null
},
timeTransparency() {
return this.calendarObjectInstance?.timeTransparency || null
},
subTitle() {
if (!this.calendarObjectInstance) {
return ''
}
const userTimezone = getTimezoneManager().getTimezoneForId(this.currentUserTimezone)
if (!userTimezone) {
logger.warn(`User timezone not found: ${this.currentUserTimezone}`)
return ''
}
const startDateInUserTz = this.calendarObjectInstance.eventComponent.startDate
.getInTimezone(userTimezone)
.jsDate
return moment(startDateInUserTz).locale(this.locale).fromNow()
},
attachments() {
return this.calendarObjectInstance?.attachments || null
},
currentUser() {
return this.$store.getters.getCurrentUserPrincipal || null
},
},
mounted() {
window.addEventListener('keydown', this.keyboardCloseEditor)
window.addEventListener('keydown', this.keyboardSaveEvent)
window.addEventListener('keydown', this.keyboardDeleteEvent)
window.addEventListener('keydown', this.keyboardDuplicateEvent)
},
beforeDestroy() {
window.removeEventListener('keydown', this.keyboardCloseEditor)
window.removeEventListener('keydown', this.keyboardSaveEvent)
window.removeEventListener('keydown', this.keyboardDeleteEvent)
window.removeEventListener('keydown', this.keyboardDuplicateEvent)
},
methods: {
/**
* Update the start and end date of this event
*
* @param {object} dates The new start and end date
*/
updateDates(dates) {
this.updateStartDate(dates.start)
this.updateEndDate(dates.end)
},
/**
* Updates the access-class of this event
*
* @param {string} accessClass The new access class
*/
updateAccessClass(accessClass) {
this.$store.commit('changeAccessClass', {
calendarObjectInstance: this.calendarObjectInstance,
accessClass,
})
},
/**
* Updates the status of the event
*
* @param {string} status The new status
*/
updateStatus(status) {
this.$store.commit('changeStatus', {
calendarObjectInstance: this.calendarObjectInstance,
status,
})
},
/**
* Updates the time-transparency of the event
*
* @param {string} timeTransparency The new time-transparency
*/
updateTimeTransparency(timeTransparency) {
this.$store.commit('changeTimeTransparency', {
calendarObjectInstance: this.calendarObjectInstance,
timeTransparency,
})
},
/**
* Adds a category to the event
*
* @param {string} category Category to add
*/
addCategory(category) {
this.$store.commit('addCategory', {
calendarObjectInstance: this.calendarObjectInstance,
category,
})
},
/**
* Removes a category from the event
*
* @param {string} category Category to remove
*/
removeCategory(category) {
this.$store.commit('removeCategory', {
calendarObjectInstance: this.calendarObjectInstance,
category,
})
},
/**
* Updates the color of the event
*
* @param {string} customColor The new color
*/
updateColor(customColor) {
this.$store.commit('changeCustomColor', {
calendarObjectInstance: this.calendarObjectInstance,
customColor,
})
},
/**
* Checks is the calendar event has attendees, but organizer or not
*
* @return {boolean}
*/
isPrivate() {
return this.calendarObjectInstance.attendees.filter((attendee) => {
if (this.currentUser.emailAddress.toLowerCase() !== (
attendee.uri.split('mailto:').length === 2
? attendee.uri.split('mailto:')[1].toLowerCase()
: attendee.uri.toLowerCase()
)) {
return attendee
}
return false
}).length === 0
},
getPreview(attachment) {
if (attachment.xNcHasPreview) {
return generateUrl(`/core/preview?fileId=${attachment.xNcFileId}&x=100&y=100&a=0`)
}
return attachment.formatType ? OC.MimeType.getIconUrl(attachment.formatType) : null
},
acceptAttachmentsModal() {
if (!this.doNotShare) {
const total = this.showModalNewAttachments.length
this.showPreloader = true
if (!this.isPrivate()) {
this.showModalNewAttachments.map(async (attachment, i) => {
// console.log('Add share', attachment)
this.sharedProgress = Math.ceil(100 * (i + 1) / total)
// add share + change attachment
try {
const data = await shareFile(`${this.attachmentsFolder}${attachment.fileName}`)
attachment.shareTypes = data?.share_type?.toString()
if (typeof attachment.attachmentProperty.getParameter('X-NC-SHARED-TYPES') === 'undefined') {
const xNcSharedTypes = new Parameter('X-NC-SHARED-TYPES', attachment.shareTypes)
attachment.attachmentProperty.setParameter(xNcSharedTypes)
}
attachment.attachmentProperty.uri = data?.url
attachment.uri = data?.url
// toastify success
} catch (e) {
// toastify err
console.error(e)
}
return attachment
})
} else {
// TODO it is not possible to delete shares, because share ID needed
/* this.showModalNewAttachments.map((attachment, i) => {
this.sharedProgress += Math.ceil(100 * (i + 1) / total)
return attachment
}) */
}
}
setTimeout(() => {
this.showPreloader = false
this.sharedProgress = 0
this.showModal = false
this.showModalNewAttachments = []
this.showModalUsers = []
this.saveEvent(this.thisAndAllFuture)
}, 500)
// trigger save event after make each attachment access
// 1) if !isPrivate get attachments NOT SHARED and SharedType is empry -> API ADD SHARE
// 2) if isPrivate get attachments SHARED and SharedType is not empty -> API DELETE SHARE
// 3) update calendarObject while pending access change
// 4) after all access changes, save Event trigger
// 5) done
},
closeAttachmentsModal() {
this.showModal = false
},
emailWithoutMailto(mailto) {
return mailto.split('mailto:').length === 2
? mailto.split('mailto:')[1].toLowerCase()
: mailto.toLowerCase()
},
getBaseName(name) {
return name.split('/').pop()
},
prepareAccessForAttachments(thisAndAllFuture = false) {
this.thisAndAllFuture = thisAndAllFuture
const newAttachments = this.calendarObjectInstance.attachments.filter(attachment => {
// get only new attachments
// TODO get NOT only new attachments =) Maybe we should filter all attachments without share-type, 'cause event can be private and AFTER save owner could add new participant
return !this.isPrivate() ? attachment.isNew && attachment.shareTypes === null : attachment.isNew && attachment.shareTypes !== null
})
// if there are new attachment and event not saved
if (newAttachments.length > 0 && !this.isPrivate()) {
// and is event NOT private,
// then add share to each attachment
// only if attachment['share-types'] is null or empty
this.showModal = true
this.showModalNewAttachments = newAttachments
this.showModalUsers = this.calendarObjectInstance.attendees.filter((attendee) => {
if (this.currentUser.emailAddress.toLowerCase() !== this.emailWithoutMailto(attendee.uri)) {
return attendee
}
return false
})
} else {
this.saveEvent(thisAndAllFuture)
}
},
saveEvent(thisAndAllFuture = false) {
// if there is new attachments and !private, then make modal with users and files/
// maybe check shared access before add file
this.saveAndLeave(thisAndAllFuture)
this.calendarObjectInstance.attachments = this.calendarObjectInstance.attachments.map(attachment => {
if (attachment.isNew) {
delete attachment.isNew
}
return attachment
})
},
},
}
</script>
<style lang="scss" scoped>
.modal-content {
padding: 16px;
position: relative;
.modal-content-preloader {
position: absolute;
top:0;
left:0;
right:0;
height: 6px;
div {
position: absolute;
top:0;
left: 0;
background: var(--color-primary-element);
height: 6px;
transition: width 0.3s linear;
}
}
}
.modal-subtitle {
font-weight: bold;
font-size: 16px;
margin-top: 16px;
}
.modal-h {
font-size: 24px;
font-weight: bold;
margin: 10px 0;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
.modal-footer-buttons {
display: flex;
:first-child {
margin-right: 6px;
}
}
}
.attachments, .users {
display: flex;
flex-wrap: wrap;
}
.attachment-list-item, .user-list-item {
width: 50%
}
.attachment-icon {
width: 40px;
height: auto;
border-radius: var(--border-radius);
}
.property-location {
margin-top: 10px;
}
.property-description {
margin-bottom: 10px;
}
:deep {
.app-sidebar-header__action {
margin-top: 0 !important;
max-height: none !important;
flex-wrap: wrap;
div {
flex-shrink: 0;
}
}
.app-sidebar-header__desc {
// We use our custom header layout for the sidebar editor
height: 0 !important;
padding: 0 !important;
margin: 0 !important;
// But keep the three-dot menu in the front
.app-sidebar-header__menu {
z-index: 1;
}
}
.app-sidebar-header__description {
flex-direction: column;
// Close button should be aligned with calendar picker (header)
padding-top: 5px;
}
}
</style>