Add Free/Busy UI

Signed-off-by: Georg Ehrke <developer@georgehrke.com>
This commit is contained in:
Georg Ehrke 2019-12-16 13:37:02 +01:00
parent 68c3021925
commit 37bf964908
No known key found for this signature in database
GPG Key ID: 9D98FD9380A1CB43
11 changed files with 664 additions and 6 deletions

View File

@ -687,3 +687,11 @@
margin-right: 8px;
}
}
.invitees-list-button-group {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
}

View File

@ -22,6 +22,7 @@
@import 'app-navigation.scss';
@import 'app-sidebar.scss';
@import 'app-settings.scss';
@import 'freebusy.scss';
@import 'fullcalendar.scss';
@import 'global.scss';
@import 'icons.scss';

87
css/freebusy.scss Normal file
View File

@ -0,0 +1,87 @@
/**
* Calendar App
*
* @copyright 2019 Georg Ehrke <oc.list@georgehrke.com>
*
* @author Georg Ehrke
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
* License as published by the Free Software Foundation; either
* version 3 of the License, or any later version.
*
* This library 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 library. If not, see <http://www.gnu.org/licenses/>.
*
*/
.modal--scheduler {
position: relative;
.fc-bgevent {
opacity: .8;
}
.blocking-event-free-busy {
border-color: red;
border-style: solid;
border-left-width: 1px;
border-right-width: 1px;
background-color: transparent;
z-index: 2;
}
.blocking-event-free-busy.blocking-event-free-busy--first-row {
border-top-width: 1px;
}
.blocking-event-free-busy.blocking-event-free-busy--last-row {
border-bottom-width: 1px;
}
.loading-indicator {
width: 100%;
position: absolute;
top: 0;
height: 50px;
margin-top: 75px;
}
}
.freebusy-caption {
display: flex;
margin-top: 10px;
&__calendar-user-types,
&__colors {
width: 50%;
display: flex;
}
&__colors {
display: flex;
justify-content: space-between;
.freebusy-caption-item {
display: flex;
align-items: center;
&__color {
height: 1em;
width: 2em;
display: block;
border: var(--color-border-dark);
opacity: 0.8;
}
&__label {
margin-left: 5px;
}
}
}
}

View File

@ -0,0 +1,217 @@
<!--
- @copyright Copyright (c) 2019 Georg Ehrke <oc.list@georgehrke.com>
-
- @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/>.
-
-->
<template>
<Modal
size="large"
:title="$t('calendar', 'Availability of attendees, resources and rooms')"
@close="$emit('close')">
<div class="modal__content modal--scheduler">
<div v-if="loadingIndicator" class="loading-indicator">
<div class="icon-loading" />
</div>
<FullCalendar
ref="freeBusyFullCalendar"
default-view="resourceTimelineDay"
:editable="false"
:selectable="false"
height="auto"
:plugins="plugins"
:event-sources="eventSources"
:time-zone="timezoneId"
:default-date="startDate"
:locales="locales"
:resources="resources"
:locale="fullCalendarLocale"
:first-day="firstDay"
scroll-time="06:00:00"
:force-event-duration="false"
:resource-label-text="$t('calendar', 'Attendees, Resources and Rooms')"
scheduler-license-key="GPL-My-Project-Is-Open-Source"
@loading="loading" />
<div class="freebusy-caption">
<div class="freebusy-caption__calendar-user-types" />
<div class="freebusy-caption__colors">
<div v-for="color in colorCaption" :key="color.color" class="freebusy-caption-item">
<div class="freebusy-caption-item__color" :style="{ 'background-color': color.color }" />
<div class="freebusy-caption-item__label">
{{ color.label }}
</div>
</div>
</div>
</div>
</div>
</Modal>
</template>
<script>
import FullCalendar from '@fullcalendar/vue'
import resourceTimelinePlugin from '@fullcalendar/resource-timeline'
import {
mapGetters,
mapState,
} from 'vuex'
import { getLocale } from '@nextcloud/l10n'
import { Modal } from '@nextcloud/vue/dist/Components/Modal'
import VTimezoneNamedTimezone from '../../../fullcalendar/vtimezoneNamedTimezoneImpl.js'
import freeBusyEventSource from '../../../fullcalendar/freeBusyEventSource.js'
import { getColorForFBType } from '../../../utils/freebusy.js'
import freeBusyFakeBlockingEventSource from '../../../fullcalendar/freeBusyFakeBlockingEventSource.js'
export default {
name: 'FreeBusy',
components: {
FullCalendar,
Modal,
},
props: {
organizer: {
type: Object,
required: true,
},
attendees: {
type: Array,
required: true,
},
startDate: {
type: Date,
required: true,
},
endDate: {
type: Date,
required: true,
},
},
data() {
return {
fullCalendarLocale: 'en',
locales: [],
firstDay: 0,
loadingIndicator: true,
}
},
computed: {
...mapGetters({
timezoneId: 'getResolvedTimezone',
}),
...mapState({
showWeekends: state => state.settings.showWeekends,
showWeekNumbers: state => state.settings.showWeekNumbers,
timezone: state => state.settings.timezone,
}),
plugins() {
return [
resourceTimelinePlugin,
VTimezoneNamedTimezone,
]
},
eventSources() {
return [
freeBusyEventSource(
this._uid,
this.organizer.attendeeProperty,
this.attendees.map((a) => a.attendeeProperty)
),
freeBusyFakeBlockingEventSource(
this._uid,
this.resources,
this.startDate,
this.endDate
),
]
},
resources() {
const resources = []
// for (const attendee of [this.organizer, ...this.attendees]) {
for (const attendee of this.attendees) {
resources.push({
id: attendee.attendeeProperty.email,
title: attendee.commonName || attendee.uri.substr(7),
})
}
return resources
},
colorCaption() {
return [{
label: this.$t('calendar', 'Busy (tentative)'),
color: getColorForFBType('BUSY-TENTATIVE'),
}, {
label: this.$t('calendar', 'Busy'),
color: getColorForFBType('BUSY'),
}, {
label: this.$t('calendar', 'Out of office'),
color: getColorForFBType('BUSY-UNAVAILABLE'),
}, {
label: this.$t('calendar', 'Unknown'),
color: getColorForFBType('UNKNOWN'),
}]
},
},
async mounted() {
this.loadFullCalendarLocale()
},
methods: {
/**
* Loads the locale data for full-calendar
*
* @returns {Promise<void>}
*/
async loadFullCalendarLocale() {
let locale = getLocale().replace('_', '-').toLowerCase()
try {
// try to load the default locale first
const fcLocale = await import('@fullcalendar/core/locales/' + locale)
this.locales.push(fcLocale)
// We have to update firstDay manually till https://github.com/fullcalendar/fullcalendar-vue/issues/36 is fixed
this.firstDay = fcLocale.week.dow
this.fullCalendarLocale = locale
} catch (e) {
try {
locale = locale.split('-')[0]
const fcLocale = await import('@fullcalendar/core/locales/' + locale)
this.locales.push(fcLocale)
// We have to update firstDay manually till https://github.com/fullcalendar/fullcalendar-vue/issues/36 is fixed
this.firstDay = fcLocale.week.dow
this.fullCalendarLocale = locale
} catch (e) {
console.debug('falling back to english locale')
}
}
},
loading(isLoading) {
this.loadingIndicator = isLoading
},
},
}
</script>
<style lang='scss' scoped>
@import '~@fullcalendar/core/main.css';
@import '~@fullcalendar/timeline/main.css';
@import '~@fullcalendar/resource-timeline/main.css';
.modal__content {
padding: 50px;
}
</style>

View File

@ -43,12 +43,26 @@
v-if="!isReadOnly && isListEmpty && hasUserEmailAddress" />
<OrganizerNoEmailError
v-if="!isReadOnly && isListEmpty && !hasUserEmailAddress" />
<button
v-if="isCreateTalkRoomButtonVisible"
:disabled="isCreateTalkRoomButtonDisabled"
@click="createTalkRoom">
{{ $t('calendar', 'Create Talk room for this event') }}
</button>
<div class="invitees-list-button-group">
<button
v-if="isCreateTalkRoomButtonVisible"
:disabled="isCreateTalkRoomButtonDisabled"
@click="createTalkRoom">
{{ $t('calendar', 'Create Talk room for this event') }}
</button>
<button v-if="!isReadOnly" :disabled="isListEmpty" @click="openFreeBusy">
{{ $t('calendar', 'Show busy times') }}
</button>
<FreeBusy
v-if="showFreeBusyModel"
:attendees="calendarObjectInstance.attendees"
:organizer="calendarObjectInstance.organizer"
:start-date="calendarObjectInstance.startDate"
:end-date="calendarObjectInstance.endDate"
@close="closeFreeBusy" />
</div>
</div>
</template>
@ -60,10 +74,12 @@ import OrganizerListItem from './OrganizerListItem'
import NoInviteesView from './NoInviteesView.vue'
import OrganizerNoEmailError from './OrganizerNoEmailError.vue'
import { createTalkRoom, doesDescriptionContainTalkLink } from '../../../services/talkService.js'
import FreeBusy from '../FreeBusy/FreeBusy.vue'
export default {
name: 'InviteesList',
components: {
FreeBusy,
OrganizerNoEmailError,
NoInviteesView,
InviteesListItem,
@ -83,6 +99,7 @@ export default {
data() {
return {
creatingTalkRoom: false,
showFreeBusyModel: false,
}
},
computed: {
@ -191,6 +208,12 @@ export default {
attendee,
})
},
openFreeBusy() {
this.showFreeBusyModel = true
},
closeFreeBusy() {
this.showFreeBusyModel = false
},
async createTalkRoom() {
const NEW_LINE = '\r\n'
try {

View File

@ -0,0 +1,81 @@
/**
* @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 getTimezoneManager from '../services/timezoneDataProviderService.js'
import { createFreeBusyRequest } from 'calendar-js'
import DateTimeValue from 'calendar-js/src/values/dateTimeValue.js'
import client from '../services/caldavService.js'
import freeBusyEventSourceFunction from './freeBusyEventSourceFunction.js'
// import AttendeeProperty from 'calendar-js/src/properties/attendeeProperty.js'
/**
* Returns an event source for free-busy
*
* @param {String} id Identification for this source
* @param {AttendeeProperty} organizer The organizer of the event
* @param {AttendeeProperty[]} attendees Array of the event's attendees
* @returns {{startEditable: boolean, resourceEditable: boolean, editable: boolean, id: string, durationEditable: boolean, events: events}}
*/
export default function(id, organizer, attendees) {
return {
id: 'free-busy-event-source-' + id,
editable: false,
startEditable: false,
durationEditable: false,
resourceEditable: false,
events: async({ start, end, timeZone }, successCallback, failureCallback) => {
console.debug(start, end, timeZone)
const timezoneObject = getTimezoneManager().getTimezoneForId(timeZone)
const startDateTime = DateTimeValue.fromJSDate(start, true)
const endDateTime = DateTimeValue.fromJSDate(end, true)
// const organizerAsAttendee = new AttendeeProperty('ATTENDEE', organizer.email)
const freeBusyComponent = createFreeBusyRequest(startDateTime, endDateTime, organizer, attendees)
const freeBusyICS = freeBusyComponent.toICS()
let outbox
try {
const outboxes = await client.calendarHomes[0].findAllScheduleOutboxes()
outbox = outboxes[0]
} catch (error) {
failureCallback(error)
return
}
let freeBusyData
try {
freeBusyData = await outbox.freeBusyRequest(freeBusyICS)
} catch (error) {
failureCallback(error)
return
}
const events = []
for (const [uri, data] of Object.entries(freeBusyData)) {
events.push(...freeBusyEventSourceFunction(uri, data.calendarData, data.success, startDateTime, endDateTime, timezoneObject))
}
console.debug(events)
successCallback(events)
},
}
}

View File

@ -0,0 +1,76 @@
/**
* @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 { getColorForFBType } from '../utils/freebusy.js'
import { getParserManager } from 'calendar-js'
/**
* Converts the response
*
* @param {String} uri URI of the resource
* @param {String} calendarData Calendar-data containing free-busy data
* @param {boolean} success Whether or not the free-busy request was successful
* @param {DateTimeValue} start The start of the fetched time-range
* @param {DateTimeValue} end The end of the fetched time-range
* @param {Timezone} timezone Timezone of user viewing data
* @returns {Object[]}
*/
export default function(uri, calendarData, success, start, end, timezone) {
if (!success) {
return [{
id: Math.random().toString(36).substring(7),
start: start.getInTimezone(timezone).jsDate.toISOString(),
end: end.getInTimezone(timezone).jsDate.toISOString(),
resourceId: uri,
rendering: 'background',
allDay: false,
backgroundColor: getColorForFBType('UNKNOWN'),
borderColor: getColorForFBType('UNKNOWN'),
}]
}
const parserManager = getParserManager()
const parser = parserManager.getParserForFileType('text/calendar')
parser.parse(calendarData)
// TODO: fix me upstream, parser only exports VEVENT, VJOURNAL and VTODO at the moment
const calendarComponent = parser._calendarComponent
const freeBusyComponent = calendarComponent.getFirstComponent('VFREEBUSY')
if (!freeBusyComponent) {
return []
}
const events = []
for (const freeBusyProperty of freeBusyComponent.getPropertyIterator('FREEBUSY')) {
/** @var {FreeBusyProperty} freeBusyProperty */
events.push({
id: Math.random().toString(36).substring(7),
start: freeBusyProperty.getFirstValue().start.getInTimezone(timezone).jsDate.toISOString(),
end: freeBusyProperty.getFirstValue().end.getInTimezone(timezone).jsDate.toISOString(),
resourceId: uri,
rendering: 'background',
backgroundColor: getColorForFBType(freeBusyProperty.type),
})
}
return events
}

View File

@ -0,0 +1,117 @@
/**
* @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/>.
*
*/
/**
* Returns an event source for free-busy
*
* @param {String} id Identification for this source
* @param {String[]} resources List of resources
* @param {Date} eventStart Start of the event being edited / created
* @param {Date} eventEnd End of the event being edited / created
* @returns {{startEditable: boolean, resourceEditable: boolean, editable: boolean, id: string, durationEditable: boolean, events: events}}
*/
export default function(id, resources, eventStart, eventEnd) {
const resourceIds = resources.map((resource) => resource.id)
return {
id: 'free-busy-fake-blocking-event-source-' + id,
editable: false,
startEditable: false,
durationEditable: false,
resourceEditable: false,
events: async({ start, end, timeZone }, successCallback, failureCallback) => {
if (resources.length === 1) {
successCallback([{
id: Math.random().toString(36).substring(7),
start: eventStart.toISOString(),
end: eventEnd.toISOString(),
allDay: false,
rendering: 'background',
classNames: [
'blocking-event-free-busy',
'blocking-event-free-busy--first-row',
'blocking-event-free-busy--last-row',
],
resourceId: resourceIds[0],
}])
} else if (resources.length === 2) {
successCallback([{
id: Math.random().toString(36).substring(7),
start: eventStart.toISOString(),
end: eventEnd.toISOString(),
allDay: false,
rendering: 'background',
classNames: [
'blocking-event-free-busy',
'blocking-event-free-busy--first-row',
],
resourceId: resourceIds[0],
}, {
id: Math.random().toString(36).substring(7),
start: eventStart.toISOString(),
end: eventEnd.toISOString(),
allDay: false,
rendering: 'background',
classNames: [
'blocking-event-free-busy',
'blocking-event-free-busy--last-row',
],
resourceId: resourceIds[1],
}])
} else {
successCallback([{
id: Math.random().toString(36).substring(7),
start: eventStart.toISOString(),
end: eventEnd.toISOString(),
allDay: false,
rendering: 'background',
classNames: [
'blocking-event-free-busy',
'blocking-event-free-busy--first-row',
],
resourceIds: resourceIds.slice(0, 1),
}, {
id: Math.random().toString(36).substring(7),
start: eventStart.toISOString(),
end: eventEnd.toISOString(),
allDay: false,
rendering: 'background',
classNames: [
'blocking-event-free-busy',
],
resourceIds: resourceIds.slice(1, -1),
}, {
id: Math.random().toString(36).substring(7),
start: eventStart.toISOString(),
end: eventEnd.toISOString(),
allDay: false,
rendering: 'background',
classNames: [
'blocking-event-free-busy',
'blocking-event-free-busy--last-row',
],
resourceIds: resourceIds.slice(-1),
}])
}
},
}
}

View File

@ -144,6 +144,7 @@ function getOrganizerFromEventComponent(eventComponent) {
return {
commonName: organizerProperty.commonName,
uri: organizerProperty.email,
attendeeProperty: organizerProperty,
}
}

View File

@ -480,6 +480,7 @@ const mutations = {
Vue.set(calendarObjectInstance, 'organizer', {})
Vue.set(calendarObjectInstance.organizer, 'commonName', commonName)
Vue.set(calendarObjectInstance.organizer, 'uri', email)
Vue.set(calendarObjectInstance.organizer, 'attendeeProperty', calendarObjectInstance.eventComponent.getFirstProperty('ORGANIZER'))
},
/**

46
src/utils/freebusy.js Normal file
View File

@ -0,0 +1,46 @@
/**
* @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/>.
*
*/
/**
* Gets the corresponding color for a given Free/Busy type
*
* @param {String} type The type of the FreeBusy property
* @returns {string}
*/
export function getColorForFBType(type = 'BUSY') {
switch (type) {
case 'FREE':
return '#55B85F'
case 'BUSY-TENTATIVE':
return '#4C81FF'
case 'BUSY':
return '#273A7F'
case 'BUSY-UNAVAILABLE':
return '#50347F'
default:
return '#DA9CBD'
}
}