Feat: Calendar widget

Signed-off-by: Hamza Mahjoubi <hamzamahjoubi221@gmail.com>
This commit is contained in:
Hamza Mahjoubi 2024-03-06 22:40:50 +01:00
parent 6a5816ffdd
commit 439c8b99e6
14 changed files with 613 additions and 93 deletions

View File

@ -28,13 +28,16 @@ use OCA\Calendar\Dashboard\CalendarWidget;
use OCA\Calendar\Dashboard\CalendarWidgetV2; use OCA\Calendar\Dashboard\CalendarWidgetV2;
use OCA\Calendar\Events\BeforeAppointmentBookedEvent; use OCA\Calendar\Events\BeforeAppointmentBookedEvent;
use OCA\Calendar\Listener\AppointmentBookedListener; use OCA\Calendar\Listener\AppointmentBookedListener;
use OCA\Calendar\Listener\CalendarReferenceListener;
use OCA\Calendar\Listener\UserDeletedListener; use OCA\Calendar\Listener\UserDeletedListener;
use OCA\Calendar\Notification\Notifier; use OCA\Calendar\Notification\Notifier;
use OCA\Calendar\Profile\AppointmentsAction; use OCA\Calendar\Profile\AppointmentsAction;
use OCA\Calendar\Reference\ReferenceProvider;
use OCP\AppFramework\App; use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Collaboration\Reference\RenderReferenceEvent;
use OCP\Dashboard\IAPIWidgetV2; use OCP\Dashboard\IAPIWidgetV2;
use OCP\User\Events\UserDeletedEvent; use OCP\User\Events\UserDeletedEvent;
use function method_exists; use function method_exists;
@ -65,9 +68,11 @@ class Application extends App implements IBootstrap {
if (method_exists($context, 'registerProfileLinkAction')) { if (method_exists($context, 'registerProfileLinkAction')) {
$context->registerProfileLinkAction(AppointmentsAction::class); $context->registerProfileLinkAction(AppointmentsAction::class);
} }
$context->registerReferenceProvider(ReferenceProvider::class);
$context->registerEventListener(BeforeAppointmentBookedEvent::class, AppointmentBookedListener::class); $context->registerEventListener(BeforeAppointmentBookedEvent::class, AppointmentBookedListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerEventListener(RenderReferenceEvent::class, CalendarReferenceListener::class);
$context->registerNotifierService(Notifier::class); $context->registerNotifierService(Notifier::class);
} }

View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
/*
* @copyright 2024 Hamza Mahjoubi <hamza.mahjoubi221@proton.me>
*
* @author 2024 Hamza Mahjoubi <hamza.mahjoubi221@proton.me>
*
* @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/>.
*/
namespace OCA\Calendar\Listener;
use OC\App\CompareVersion;
use OCA\Calendar\AppInfo\Application;
use OCP\App\IAppManager;
use OCP\AppFramework\Services\IInitialState;
use OCP\Collaboration\Reference\RenderReferenceEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\IAppData;
use OCP\IConfig;
use OCP\Util;
/**
* @template-implements IEventListener<Event|RenderReferenceEvent>
*/
class CalendarReferenceListener implements IEventListener {
/** @var IInitialState */
private $initialStateService;
/** @var IAppManager */
private $appManager;
/** @var IConfig */
private $config;
/** @var CompareVersion */
private $compareVersion;
private IAppData $appData;
public function __construct(
IInitialState $initialStateService,
IAppManager $appManager,
IConfig $config,
IAppData $appData,
CompareVersion $compareVersion,
) {
$this->config = $config;
$this->initialStateService = $initialStateService;
$this->appManager = $appManager;
$this->appData = $appData;
$this->compareVersion = $compareVersion;
}
public function handle(Event $event): void {
if (!$event instanceof RenderReferenceEvent) {
return;
}
$defaultEventLimit = $this->config->getAppValue('calendar', 'eventLimit', 'yes');
$defaultInitialView = $this->config->getAppValue('calendar', 'currentView', 'dayGridMonth');
$defaultShowWeekends = $this->config->getAppValue('calendar', 'showWeekends', 'yes');
$defaultWeekNumbers = $this->config->getAppValue('calendar', 'showWeekNr', 'no');
$defaultSkipPopover = $this->config->getAppValue('calendar', 'skipPopover', 'no');
$defaultTimezone = $this->config->getAppValue('calendar', 'timezone', 'automatic');
$defaultSlotDuration = $this->config->getAppValue('calendar', 'slotDuration', '00:30:00');
$defaultDefaultReminder = $this->config->getAppValue('calendar', 'defaultReminder', 'none');
$appVersion = $this->config->getAppValue('calendar', 'installed_version', '');
$forceEventAlarmType = $this->config->getAppValue('calendar', 'forceEventAlarmType', '');
if (!in_array($forceEventAlarmType, ['DISPLAY', 'EMAIL'], true)) {
$forceEventAlarmType = false;
}
$showResources = $this->config->getAppValue('calendar', 'showResources', 'yes') === 'yes';
$publicCalendars = $this->config->getAppValue('calendar', 'publicCalendars', '');
$talkApiVersion = version_compare($this->appManager->getAppVersion('spreed'), '12.0.0', '>=') ? 'v4' : 'v1';
$tasksEnabled = $this->appManager->isEnabledForUser('tasks');
$circleVersion = $this->appManager->getAppVersion('circles');
$isCirclesEnabled = $this->appManager->isEnabledForUser('circles') === true;
// if circles is not installed, we use 0.0.0
$isCircleVersionCompatible = $this->compareVersion->isCompatible($circleVersion ? $circleVersion : '0.0.0', '22');
$this->initialStateService->provideInitialState('app_version', $appVersion);
$this->initialStateService->provideInitialState('event_limit', $defaultEventLimit);
$this->initialStateService->provideInitialState('first_run', false);
$this->initialStateService->provideInitialState('initial_view', $defaultInitialView);
$this->initialStateService->provideInitialState('show_weekends', $defaultShowWeekends);
$this->initialStateService->provideInitialState('show_week_numbers', $defaultWeekNumbers === 'yes');
$this->initialStateService->provideInitialState('skip_popover', true);
$this->initialStateService->provideInitialState('talk_enabled', false);
$this->initialStateService->provideInitialState('talk_api_version', $talkApiVersion);
$this->initialStateService->provideInitialState('show_tasks', false);
$this->initialStateService->provideInitialState('timezone', $defaultTimezone);
$this->initialStateService->provideInitialState('attachments_folder', '/Calendar');
$this->initialStateService->provideInitialState('slot_duration', $defaultSlotDuration);
$this->initialStateService->provideInitialState('default_reminder', $defaultDefaultReminder);
$this->initialStateService->provideInitialState('tasks_enabled', $tasksEnabled);
$this->initialStateService->provideInitialState('hide_event_export', true);
$this->initialStateService->provideInitialState('force_event_alarm_type', $forceEventAlarmType);
$this->initialStateService->provideInitialState('disable_appointments', true);
$this->initialStateService->provideInitialState('can_subscribe_link', false);
$this->initialStateService->provideInitialState('show_resources', $showResources);
$this->initialStateService->provideInitialState('publicCalendars', $publicCalendars);
Util::addScript(Application::APP_ID, 'calendar-reference');
}
}

View File

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/*
* @copyright 2024 Hamza Mahjoubi <hamza.mahjoubi221@proton.me>
*
* @author 2024 Hamza Mahjoubi <hamza.mahjoubi221@proton.me>
*
* @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/>.
*/
namespace OCA\Calendar\Reference;
use OCA\Calendar\AppInfo\Application;
use OCP\Collaboration\Reference\ADiscoverableReferenceProvider;
use OCP\Collaboration\Reference\IReference;
use OCP\Collaboration\Reference\Reference;
use OCP\IL10N;
use OCP\IURLGenerator;
class ReferenceProvider extends ADiscoverableReferenceProvider {
public function __construct(
private IL10N $l10n,
private IURLGenerator $urlGenerator,
) {
}
public function getId(): string {
return 'calendar';
}
/**
* @inheritDoc
*/
public function getTitle(): string {
return 'Calendar';
}
/**
* @inheritDoc
*/
public function getOrder(): int {
return 20;
}
/**
* @inheritDoc
*/
public function getIconUrl(): string {
return $this->urlGenerator->getAbsoluteURL(
$this->urlGenerator->imagePath(Application::APP_ID, 'calendar-dark.svg')
);
}
public function matchReference(string $referenceText): bool {
$start = $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_ID);
$startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_ID);
return preg_match('/^' . preg_quote($start, '/') . '\/p\/[a-zA-Z0-9]+$/i', $referenceText) === 1 || preg_match('/^' . preg_quote($startIndex, '/') . '\/p\/[a-zA-Z0-9]+$/i', $referenceText) === 1;
}
public function resolveReference(string $referenceText): ?IReference {
if ($this->matchReference($referenceText)) {
$token = $this->getCalendarTokenFromLink($referenceText);
$reference = new Reference($referenceText);
$reference->setTitle('calendar');
$reference->setDescription($token);
$reference->setRichObject(
'calendar_widget',
[
'title' => 'calendar',
'token' => $token,
'url' => $referenceText,]
);
return $reference;
}
return null;
}
private function getCalendarTokenFromLink(string $url): ?string {
if (preg_match('/\/p\/([a-zA-Z0-9]+)/', $url, $output_array)) {
return $output_array[1];
}
return $url;
}
public function getCachePrefix(string $referenceId): string {
return '';
}
/**
* @inheritDoc
*/
public function getCacheKey(string $referenceId): ?string {
return $referenceId;
}
}

View File

@ -22,7 +22,8 @@
<template> <template>
<div class="datepicker-button-section"> <div class="datepicker-button-section">
<NcButton v-shortkey="previousShortKeyConf" <NcButton v-if="!isWidget"
v-shortkey="previousShortKeyConf"
:aria-label="previousLabel" :aria-label="previousLabel"
class="datepicker-button-section__previous button" class="datepicker-button-section__previous button"
:name="previousLabel" :name="previousLabel"
@ -32,20 +33,23 @@
<ChevronLeftIcon :size="22" /> <ChevronLeftIcon :size="22" />
</template> </template>
</NcButton> </NcButton>
<NcButton class="datepicker-button-section__datepicker-label button datepicker-label" <NcButton v-if="!isWidget"
class="datepicker-button-section__datepicker-label button datepicker-label"
@click.stop.prevent="toggleDatepicker" @click.stop.prevent="toggleDatepicker"
@mousedown.stop.prevent="doNothing" @mousedown.stop.prevent="doNothing"
@mouseup.stop.prevent="doNothing"> @mouseup.stop.prevent="doNothing">
{{ selectedDate | formatDateRange(view, locale) }} {{ selectedDate | formatDateRange(view, locale) }}
</NcButton> </NcButton>
<DatePicker ref="datepicker" <DatePicker ref="datepicker"
class="datepicker-button-section__datepicker" :class="isWidget ? 'datepicker-widget':'datepicker-button-section__datepicker'"
:append-to-body="isWidget"
:date="selectedDate" :date="selectedDate"
:is-all-day="true" :is-all-day="true"
:open.sync="isDatepickerOpen" :open.sync="isDatepickerOpen"
:type="view === 'multiMonthYear' ? 'year' : 'date'" :type="view === 'multiMonthYear' ? 'year' : 'date'"
@change="navigateToDate" /> @change="navigateToDate" />
<NcButton v-shortkey="nextShortKeyConf" <NcButton v-if="!isWidget"
v-shortkey="nextShortKeyConf"
:aria-label="nextLabel" :aria-label="nextLabel"
class="datepicker-button-section__next button" class="datepicker-button-section__next button"
:name="nextLabel" :name="nextLabel"
@ -82,6 +86,12 @@ export default {
filters: { filters: {
formatDateRange, formatDateRange,
}, },
props: {
isWidget: {
type: Boolean,
default: false,
},
},
data() { data() {
return { return {
isDatepickerOpen: false, isDatepickerOpen: false,
@ -92,6 +102,9 @@ export default {
locale: (state) => state.settings.momentLocale, locale: (state) => state.settings.momentLocale,
}), }),
selectedDate() { selectedDate() {
if (this.isWidget) {
return getDateFromFirstdayParam(this.$store.getters.widgetDate)
}
return getDateFromFirstdayParam(this.$route.params?.firstDay ?? 'now') return getDateFromFirstdayParam(this.$route.params?.firstDay ?? 'now')
}, },
previousShortKeyConf() { previousShortKeyConf() {
@ -139,6 +152,9 @@ export default {
} }
}, },
view() { view() {
if (this.isWidget) {
return this.$store.getters.widgetView
}
return this.$route.params.view return this.$route.params.view
}, },
}, },
@ -190,17 +206,21 @@ export default {
this.navigateToDate(newDate) this.navigateToDate(newDate)
}, },
navigateToDate(date) { navigateToDate(date) {
const name = this.$route.name if (this.isWidget) {
const params = Object.assign({}, this.$route.params, { this.$store.commit('setWidgetDate', { widgetDate: getYYYYMMDDFromDate(date) })
firstDay: getYYYYMMDDFromDate(date), } else {
}) const name = this.$route.name
const params = Object.assign({}, this.$route.params, {
firstDay: getYYYYMMDDFromDate(date),
})
// Don't push new route when day didn't change // Don't push new route when day didn't change
if (this.$route.params.firstDay === getYYYYMMDDFromDate(date)) { if (this.$route.params.firstDay === getYYYYMMDDFromDate(date)) {
return return
}
this.$router.push({ name, params })
} }
this.$router.push({ name, params })
}, },
toggleDatepicker() { toggleDatepicker() {
this.isDatepickerOpen = !this.isDatepickerOpen this.isDatepickerOpen = !this.isDatepickerOpen
@ -212,3 +232,9 @@ export default {
}, },
} }
</script> </script>
<style lang="scss">
.datepicker-widget{
width: 135px;
margin: 2px 5px 5px 5px;
}
</style>

View File

@ -59,6 +59,12 @@ export default {
components: { components: {
NcButton, NcButton,
}, },
props: {
isWidget: {
type: Boolean,
default: false,
},
},
computed: { computed: {
isAgendaDayViewSelected() { isAgendaDayViewSelected() {
return this.selectedView === 'timeGridDay' return this.selectedView === 'timeGridDay'
@ -76,22 +82,30 @@ export default {
return this.selectedView === 'listMonth' return this.selectedView === 'listMonth'
}, },
selectedView() { selectedView() {
if (this.isWidget) {
return this.$store.getters.widgetView
}
return this.$route.params.view return this.$route.params.view
}, },
}, },
methods: { methods: {
view(viewName) { view(viewName) {
const name = this.$route.name if (this.isWidget) {
const params = Object.assign({}, this.$route.params, { this.$store.commit('setWidgetView', { viewName })
view: viewName, } else {
}) const name = this.$route.name
const params = Object.assign({}, this.$route.params, {
view: viewName,
})
// Don't push new route when view didn't change
if (this.$route.params.view === viewName) {
return
}
this.$router.push({ name, params })
// Don't push new route when view didn't change
if (this.$route.params.view === viewName) {
return
} }
this.$router.push({ name, params })
}, },
}, },
} }

View File

@ -1,14 +1,14 @@
<template> <template>
<header id="embed-header" role="banner"> <header :id="isWidget? 'widget-header' :'embed-header'" role="banner">
<div class="embed-header__date-section"> <div :class="isWidget?'widget-header__date-section' :'embed-header__date-section'">
<AppNavigationHeaderDatePicker /> <AppNavigationHeaderDatePicker :is-widget="isWidget" />
<AppNavigationHeaderTodayButton /> <AppNavigationHeaderTodayButton v-if="!isWidget" />
</div> </div>
<div class="embed-header__views-section"> <div :class="isWidget?'widget-header__views-section' :'embed-header__views-section'">
<AppNavigationHeaderViewButtons /> <AppNavigationHeaderViewButtons :is-widget="isWidget" />
</div> </div>
<!-- TODO have one button per calendar --> <!-- TODO have one button per calendar -->
<div class="embed-header__share-section"> <div v-if="!isWidget" class="widget-header__share-section">
<Actions> <Actions>
<template #icon> <template #icon>
<Download :size="20" decorative /> <Download :size="20" decorative />
@ -74,6 +74,12 @@ export default {
CalendarBlank, CalendarBlank,
Download, Download,
}, },
props: {
isWidget: {
type: Boolean,
default: false,
},
},
computed: { computed: {
...mapGetters({ ...mapGetters({
subscriptions: 'sortedSubscriptions', subscriptions: 'sortedSubscriptions',
@ -98,3 +104,34 @@ export default {
}, },
} }
</script> </script>
<style lang="scss">
#widget-header {
top: 0;
left: 0;
height: 50px;
width: 100%;
box-sizing: border-box;
background-color: var(--color-main-background);
border-bottom: 1px solid var(--color-border);
overflow: visible;
z-index: 2000;
display: flex;
.widget-header__date-section{
display: flex;
gap: 5px;
}
.view-button-section {
display: flex;
}
.datepicker-button-section {
display: flex;
&__datepicker-label {
min-width: 150px;
}
}
}
</style>

View File

@ -23,6 +23,7 @@
<template> <template>
<FullCalendar ref="fullCalendar" <FullCalendar ref="fullCalendar"
:class="isWidget? 'fullcalendar-widget': ''"
:options="options" /> :options="options" />
</template> </template>
@ -72,6 +73,10 @@ export default {
FullCalendar, FullCalendar,
}, },
props: { props: {
isWidget: {
type: Boolean,
default: false,
},
/** /**
* Whether or not the user is authenticated * Whether or not the user is authenticated
*/ */
@ -104,8 +109,8 @@ export default {
options() { options() {
return { return {
// Initialization: // Initialization:
initialDate: getYYYYMMDDFromFirstdayParam(this.$route.params.firstDay), initialDate: getYYYYMMDDFromFirstdayParam(this.$route?.params?.firstDay ?? 'now'),
initialView: this.$route.params.view, initialView: this.$route?.params.view ?? 'dayGridMonth',
// Data // Data
eventSources: this.eventSources, eventSources: this.eventSources,
// Plugins // Plugins
@ -114,12 +119,12 @@ export default {
editable: this.isEditable, editable: this.isEditable,
selectable: this.isAuthenticatedUser, selectable: this.isAuthenticatedUser,
eventAllow, eventAllow,
eventClick: eventClick(this.$store, this.$router, this.$route, window), eventClick: eventClick(this.$store, this.$router, this.$route, window, this.isWidget, this.$refs.fullCalendar),
eventDrop: (...args) => eventDrop(this.$store, this.$refs.fullCalendar.getApi())(...args), eventDrop: this.isWidget ? false : (...args) => eventDrop(this.$store, this.$refs.fullCalendar.getApi())(...args),
eventResize: eventResize(this.$store), eventResize: this.isWidget ? false : eventResize(this.$store),
navLinkDayClick: navLinkDayClick(this.$router, this.$route), navLinkDayClick: this.isWidget ? false : navLinkDayClick(this.$router, this.$route),
navLinkWeekClick: navLinkWeekClick(this.$router, this.$route), navLinkWeekClick: this.isWidget ? false : navLinkWeekClick(this.$router, this.$route),
select: select(this.$store, this.$router, this.$route, window), select: this.isWidget ? false : select(this.$store, this.$router, this.$route, window),
navLinks: true, navLinks: true,
// Localization // Localization
...getDateFormattingConfig(), ...getDateFormattingConfig(),
@ -151,6 +156,12 @@ export default {
eventSources() { eventSources() {
return this.$store.getters.enabledCalendars.map(eventSource(this.$store)) return this.$store.getters.enabledCalendars.map(eventSource(this.$store))
}, },
widgetView() {
return this.$store.getters.widgetView
},
widgetDate() {
return this.$store.getters.widgetDate
},
/** /**
* FullCalendar Plugins * FullCalendar Plugins
* *
@ -170,11 +181,19 @@ export default {
isEditable() { isEditable() {
// We do not allow drag and drop when the editor is open. // We do not allow drag and drop when the editor is open.
return this.isAuthenticatedUser return this.isAuthenticatedUser
&& this.$route.name !== 'EditPopoverView' && this.$route?.name !== 'EditPopoverView'
&& this.$route.name !== 'EditSidebarView' && this.$route?.name !== 'EditSidebarView'
}, },
}, },
watch: { watch: {
widgetView(newView) {
const calendarApi = this.$refs.fullCalendar.getApi()
calendarApi.changeView(newView)
},
widgetDate(newDate) {
const calendarApi = this.$refs.fullCalendar.getApi()
calendarApi.gotoDate(getYYYYMMDDFromFirstdayParam(newDate))
},
modificationCount: debounce(function() { modificationCount: debounce(function() {
const calendarApi = this.$refs.fullCalendar.getApi() const calendarApi = this.$refs.fullCalendar.getApi()
calendarApi.refetchEvents() calendarApi.refetchEvents()
@ -226,40 +245,42 @@ export default {
* This view is not used as a router view, * This view is not used as a router view,
* hence we can't use beforeRouteUpdate directly. * hence we can't use beforeRouteUpdate directly.
*/ */
this.$router.beforeEach((to, from, next) => { if (!this.isWidget) {
if (to.params.firstDay !== from.params.firstDay) { this.$router.beforeEach((to, from, next) => {
const calendarApi = this.$refs.fullCalendar.getApi() if (to.params.firstDay !== from.params.firstDay) {
calendarApi.gotoDate(getYYYYMMDDFromFirstdayParam(to.params.firstDay)) const calendarApi = this.$refs.fullCalendar.getApi()
} calendarApi.gotoDate(getYYYYMMDDFromFirstdayParam(to.params.firstDay))
if (to.params.view !== from.params.view) { }
const calendarApi = this.$refs.fullCalendar.getApi() if (to.params.view !== from.params.view) {
calendarApi.changeView(to.params.view) const calendarApi = this.$refs.fullCalendar.getApi()
this.saveNewView(to.params.view) calendarApi.changeView(to.params.view)
} this.saveNewView(to.params.view)
}
if ((from.name === 'NewPopoverView' || from.name === 'NewSidebarView') if ((from.name === 'NewPopoverView' || from.name === 'NewSidebarView')
&& to.name !== 'NewPopoverView' && to.name !== 'NewPopoverView'
&& to.name !== 'NewSidebarView') { && to.name !== 'NewSidebarView') {
const calendarApi = this.$refs.fullCalendar.getApi() const calendarApi = this.$refs.fullCalendar.getApi()
calendarApi.unselect() calendarApi.unselect()
} }
next() next()
}) })
// Trigger the select event programmatically on initial page load to show the new event // Trigger the select event programmatically on initial page load to show the new event
// in the grid. Wait for the next tick because the ref isn't available right away. // in the grid. Wait for the next tick because the ref isn't available right away.
await this.$nextTick() await this.$nextTick()
if (['NewPopoverView', 'NewSidebarView'].includes(this.$route.name)) { if (['NewPopoverView', 'NewSidebarView'].includes(this.$route.name)) {
const start = new Date(parseInt(this.$route.params.dtstart) * 1000) const start = new Date(parseInt(this.$route.params.dtstart) * 1000)
const end = new Date(parseInt(this.$route.params.dtend) * 1000) const end = new Date(parseInt(this.$route.params.dtend) * 1000)
if (!isNaN(start.getTime()) && !isNaN(end.getTime())) { if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
const calendarApi = this.$refs.fullCalendar.getApi() const calendarApi = this.$refs.fullCalendar.getApi()
calendarApi.select({ calendarApi.select({
start, start,
end, end,
allDay: this.$route.params.allDay === '1', allDay: this.$route.params.allDay === '1',
}) })
}
} }
} }
}, },
@ -277,7 +298,7 @@ export default {
} }
</script> </script>
<style lang="scss"> <style scoped lang="scss">
.calendar-grid-checkbox { .calendar-grid-checkbox {
border-style: solid; border-style: solid;
border-width: 2px; border-width: 2px;
@ -293,4 +314,10 @@ export default {
height: 16px; height: 16px;
width: 16px; width: 16px;
} }
.fullcalendar-widget{
min-height: 500px;
:deep(.fc-col-header-cell-cushion){
font-size: 9px;
}
}
</style> </style>

View File

@ -36,17 +36,23 @@ import { emit } from '@nextcloud/event-bus'
* @param {object} router The Vue router * @param {object} router The Vue router
* @param {object} route The current Vue route * @param {object} route The current Vue route
* @param {Window} window The window object * @param {Window} window The window object
* @param {boolean} isWidget Whether the calendar is embedded in a widget
* @param {object} widgetRef
* @return {Function} * @return {Function}
*/ */
export default function(store, router, route, window) { export default function(store, router, route, window, isWidget = false, widgetRef = undefined) {
return function({ event }) { return function({ event }) {
if (isWidget) {
store.commit('setWidgetRef', { widgetRef: widgetRef.$el })
}
switch (event.extendedProps.objectType) { switch (event.extendedProps.objectType) {
case 'VEVENT': case 'VEVENT':
handleEventClick(event, store, router, route, window) handleEventClick(event, store, router, route, window, isWidget)
break break
case 'VTODO': case 'VTODO':
handleToDoClick(event, store, route, window) handleToDoClick(event, store, route, window, isWidget)
break break
} }
} }
@ -60,8 +66,13 @@ export default function(store, router, route, window) {
* @param {object} router The Vue router * @param {object} router The Vue router
* @param {object} route The current Vue route * @param {object} route The current Vue route
* @param {Window} window The window object * @param {Window} window The window object
* @param {boolean} isWidget Whether the calendar is embedded in a widget
*/ */
function handleEventClick(event, store, router, route, window) { function handleEventClick(event, store, router, route, window, isWidget = false) {
if (isWidget) {
store.commit('setSelectedEvent', { object: event.extendedProps.objectId, recurrenceId: event.extendedProps.recurrenceId })
return
}
let desiredRoute = store.state.settings.skipPopover let desiredRoute = store.state.settings.skipPopover
? 'EditSidebarView' ? 'EditSidebarView'
: 'EditPopoverView' : 'EditPopoverView'
@ -95,10 +106,11 @@ function handleEventClick(event, store, router, route, window) {
* @param {object} store The Vuex store * @param {object} store The Vuex store
* @param {object} route The current Vue route * @param {object} route The current Vue route
* @param {Window} window The window object * @param {Window} window The window object
* @param isWidget
*/ */
function handleToDoClick(event, store, route, window) { function handleToDoClick(event, store, route, window, isWidget = false) {
if (isPublicOrEmbeddedRoute(route.name)) { if (isWidget || isPublicOrEmbeddedRoute(route.name)) {
return return
} }

View File

@ -41,6 +41,13 @@ import { showError } from '@nextcloud/dialogs'
* See inline for more documentation * See inline for more documentation
*/ */
export default { export default {
props: {
// Whether or not the calendar is embedded in a widget
isWidget: {
type: Boolean,
default: false,
},
},
data() { data() {
return { return {
// Indicator whether or not the event is currently loading, saving or being deleted // Indicator whether or not the event is currently loading, saving or being deleted
@ -396,6 +403,10 @@ export default {
* Closes the editor and returns to normal calendar-view * Closes the editor and returns to normal calendar-view
*/ */
closeEditor() { closeEditor() {
if (this.isWidget) {
this.$store.commit('closeWidgetEventDetails')
return
}
const params = Object.assign({}, this.$store.state.route.params) const params = Object.assign({}, this.$store.state.route.params)
delete params.object delete params.object
delete params.recurrenceId delete params.recurrenceId

29
src/reference.js Normal file
View File

@ -0,0 +1,29 @@
import { registerWidget, NcCustomPickerRenderResult } from '@nextcloud/vue/dist/Functions/registerReference.js'
import { linkTo } from '@nextcloud/router'
import { getRequestToken } from '@nextcloud/auth'
import { translate, translatePlural } from '@nextcloud/l10n'
import '../css/calendar.scss'
__webpack_nonce__ = btoa(getRequestToken()) // eslint-disable-line
__webpack_public_path__ = linkTo('calendar', 'js/') // eslint-disable-line
registerWidget('calendar_widget', async (el, { richObjectType, richObject, accessible, interactive }) => {
const { default: Vue } = await import('vue')
const { default: Calendar } = await import('./views/Calendar.vue')
const { default: store } = await import('./store/index.js')
Vue.prototype.$t = translate
Vue.prototype.$n = translatePlural
Vue.mixin({ methods: { t, n } })
const Widget = Vue.extend(Calendar)
const vueElement = new Widget({
store,
propsData: {
isWidget: true,
referenceToken: richObject.token,
},
}).$mount(el)
return new NcCustomPickerRenderResult(vueElement.$el, vueElement)
}, (el, renderResult) => {
renderResult.object.$destroy()
}, true)

View File

@ -59,6 +59,11 @@ const state = {
calendarsById: {}, calendarsById: {},
initialCalendarsLoaded: false, initialCalendarsLoaded: false,
editCalendarModal: undefined, editCalendarModal: undefined,
widgetView: 'dayGridMonth',
widgetDate: 'now',
widgetEventDetailsOpen: false,
widgetEventDetails: {},
widgetRef: undefined,
} }
const mutations = { const mutations = {
@ -83,6 +88,30 @@ const mutations = {
state.trashBin = trashBin state.trashBin = trashBin
}, },
setWidgetView(state, { viewName }) {
state.widgetView = viewName
},
setWidgetDate(state, { widgetDate }) {
state.widgetDate = widgetDate
},
setWidgetRef(state, { widgetRef }) {
state.widgetRef = widgetRef
},
setSelectedEvent(state, { object, recurrenceId }) {
state.widgetEventDetailsOpen = true
state.widgetEventDetails = {
object,
recurrenceId,
}
},
closeWidgetEventDetails(state) {
state.widgetEventDetailsOpen = false
},
addScheduleInbox(state, { scheduleInbox }) { addScheduleInbox(state, { scheduleInbox }) {
state.scheduleInbox = scheduleInbox state.scheduleInbox = scheduleInbox
}, },
@ -444,6 +473,22 @@ const getters = {
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
}, },
widgetView(state) {
return state.widgetView
},
widgetDate(state) {
return state.widgetDate
},
widgetEventDetailsOpen(state) {
return state.widgetEventDetailsOpen
},
widgetRef(state) {
return state.widgetRef
},
hasTrashBin(state) { hasTrashBin(state) {
return state.trashBin !== undefined && state.trashBin.retentionDuration !== 0 return state.trashBin !== undefined && state.trashBin.retentionDuration !== 0
}, },

View File

@ -21,8 +21,20 @@
--> -->
<template> <template>
<NcContent app-name="calendar" :class="classNames"> <div v-if="isWidget" class="calendar-Widget">
<AppNavigation v-if="!isEmbedded && !showEmptyCalendarScreen"> <EmbedTopNavigation :is-widget="true" />
<CalendarGrid v-if="!showEmptyCalendarScreen"
ref="calendarGridWidget"
:is-widget="isWidget"
:is-authenticated-user="isAuthenticatedUser" />
<EmptyCalendar v-else />
<EditSimple v-if="showWidgetEventDetails" :is-widget="true" />
</div>
<NcContent v-else app-name="calendar" :class="classNames">
<AppNavigation v-if="!isWidget &&!isEmbedded && !showEmptyCalendarScreen">
<!-- Date Picker, View Buttons, Today Button --> <!-- Date Picker, View Buttons, Today Button -->
<AppNavigationHeader :is-public="!isAuthenticatedUser" /> <AppNavigationHeader :is-public="!isAuthenticatedUser" />
<template #list> <template #list>
@ -77,6 +89,7 @@ import EmbedTopNavigation from '../components/AppNavigation/EmbedTopNavigation.v
import EmptyCalendar from '../components/EmptyCalendar.vue' import EmptyCalendar from '../components/EmptyCalendar.vue'
import CalendarGrid from '../components/CalendarGrid.vue' import CalendarGrid from '../components/CalendarGrid.vue'
import EditCalendarModal from '../components/AppNavigation/EditCalendarModal.vue' import EditCalendarModal from '../components/AppNavigation/EditCalendarModal.vue'
import EditSimple from './EditSimple.vue'
// Import CalDAV related methods // Import CalDAV related methods
import { import {
@ -123,6 +136,17 @@ export default {
CalendarListNew, CalendarListNew,
Trashbin, Trashbin,
EditCalendarModal, EditCalendarModal,
EditSimple,
},
props: {
isWidget: {
type: Boolean,
default: false,
},
referenceToken: {
type: String,
required: false,
},
}, },
data() { data() {
return { return {
@ -152,29 +176,39 @@ export default {
attachmentsFolder: state => state.settings.attachmentsFolder, attachmentsFolder: state => state.settings.attachmentsFolder,
}), }),
defaultDate() { defaultDate() {
return getYYYYMMDDFromFirstdayParam(this.$route.params?.firstDay ?? 'now') return getYYYYMMDDFromFirstdayParam(this.$route?.params?.firstDay ?? 'now')
}, },
isEditable() { isEditable() {
// We do not allow drag and drop when the editor is open. // We do not allow drag and drop when the editor is open.
return !this.isPublicShare return !this.isPublicShare
&& !this.isEmbedded && !this.isEmbedded
&& this.$route.name !== 'EditPopoverView' && !this.isWidget
&& this.$route.name !== 'EditSidebarView' && this.$route?.name !== 'EditPopoverView'
&& this.$route?.name !== 'EditSidebarView'
}, },
isSelectable() { isSelectable() {
return !this.isPublicShare && !this.isEmbedded return !this.isPublicShare && !this.isEmbedded && !this.isWidget
}, },
isAuthenticatedUser() { isAuthenticatedUser() {
return !this.isPublicShare && !this.isEmbedded return !this.isPublicShare && !this.isEmbedded && !this.isWidget
}, },
isPublicShare() { isPublicShare() {
if (this.isWidget) {
return false
}
return this.$route.name.startsWith('Public') return this.$route.name.startsWith('Public')
}, },
isEmbedded() { isEmbedded() {
if (this.isWidget) {
return false
}
return this.$route.name.startsWith('Embed') return this.$route.name.startsWith('Embed')
}, },
showWidgetEventDetails() {
return this.$store.getters.widgetEventDetailsOpen && this.$refs.calendarGridWidget.$el === this.$store.getters.widgetRef
},
showHeader() { showHeader() {
return this.isPublicShare && this.isEmbedded return this.isPublicShare && this.isEmbedded && this.isWidget
}, },
classNames() { classNames() {
if (this.isEmbedded) { if (this.isEmbedded) {
@ -229,9 +263,9 @@ export default {
}) })
this.$store.dispatch('initializeCalendarJsConfig') this.$store.dispatch('initializeCalendarJsConfig')
if (this.$route.name.startsWith('Public') || this.$route.name.startsWith('Embed')) { if (this.$route?.name.startsWith('Public') || this.$route?.name.startsWith('Embed') || this.isWidget) {
await initializeClientForPublicView() await initializeClientForPublicView()
const tokens = this.$route.params.tokens.split('-') const tokens = this.isWidget ? [this.referenceToken] : this.$route.params.tokens.split('-')
const calendars = await this.$store.dispatch('getPublicCalendars', { tokens }) const calendars = await this.$store.dispatch('getPublicCalendars', { tokens })
this.loadingCalendars = false this.loadingCalendars = false
@ -302,3 +336,9 @@ export default {
}, },
} }
</script> </script>
<style lang="scss">
.calendar-Widget {
width: 100%;
}
</style>
```

View File

@ -23,7 +23,7 @@
<template> <template>
<Popover ref="popover" <Popover ref="popover"
:shown="isVisible" :shown="showPopover"
:auto-hide="false" :auto-hide="false"
:placement="placement" :placement="placement"
:boundary="boundaryElement" :boundary="boundaryElement"
@ -148,7 +148,8 @@
:calendar-id="calendarId" :calendar-id="calendarId"
@close="closeEditorAndSkipAction" /> @close="closeEditorAndSkipAction" />
<SaveButtons class="event-popover__buttons" <SaveButtons v-if="!isWidget"
class="event-popover__buttons"
:can-create-recurrence-exception="canCreateRecurrenceException" :can-create-recurrence-exception="canCreateRecurrenceException"
:is-new="isNew" :is-new="isNew"
:is-read-only="isReadOnlyOrViewing" :is-read-only="isReadOnlyOrViewing"
@ -236,7 +237,7 @@ export default {
placement: 'auto', placement: 'auto',
hasLocation: false, hasLocation: false,
hasDescription: false, hasDescription: false,
boundaryElement: document.querySelector('#app-content-vue > .fc'), boundaryElement: null,
isVisible: true, isVisible: true,
isViewing: true, isViewing: true,
} }
@ -244,15 +245,22 @@ export default {
computed: { computed: {
...mapState({ ...mapState({
hideEventExport: (state) => state.settings.hideEventExport, hideEventExport: (state) => state.settings.hideEventExport,
widgetEventDetailsOpen: (state) => state.calendars.widgetEventDetailsOpen,
widgetEventDetails: (state) => state.calendars.widgetEventDetails,
widgetRef: (state) => state.calendars.widgetRef,
}), }),
showPopover() {
return this.isVisible || this.widgetEventDetailsOpen
},
/** /**
* Returns true if the current event is read only or the user is viewing the event * Returns true if the current event is read only or the user is viewing the event
* *
* @return {boolean} * @return {boolean}
*/ */
isReadOnlyOrViewing() { isReadOnlyOrViewing() {
return this.isReadOnly || this.isViewing return this.isReadOnly || this.isViewing || this.isWidget
}, },
}, },
watch: { watch: {
@ -260,7 +268,7 @@ export default {
this.repositionPopover() this.repositionPopover()
// Hide popover when changing the view until the user selects a slot again // Hide popover when changing the view until the user selects a slot again
this.isVisible = to.params.view === from.params.view this.isVisible = to?.params.view === from?.params.view
}, },
calendarObjectInstance() { calendarObjectInstance() {
this.hasLocation = false this.hasLocation = false
@ -281,7 +289,15 @@ export default {
}, },
}, },
}, },
mounted() { async mounted() {
if (this.isWidget) {
const objectId = this.widgetEventDetails.object
const recurrenceId = this.widgetEventDetails.recurrenceId
await this.$store.dispatch('getCalendarObjectInstanceByObjectIdAndRecurrenceId', { objectId, recurrenceId })
this.calendarId = this.calendarObject.calendarId
this.isLoading = false
}
this.boundaryElement = this.isWidget ? document.querySelector('.fc') : document.querySelector('#app-content-vue > .fc')
window.addEventListener('keydown', this.keyboardCloseEditor) window.addEventListener('keydown', this.keyboardCloseEditor)
window.addEventListener('keydown', this.keyboardSaveEvent) window.addEventListener('keydown', this.keyboardSaveEvent)
window.addEventListener('keydown', this.keyboardDeleteEvent) window.addEventListener('keydown', this.keyboardDeleteEvent)
@ -314,8 +330,13 @@ export default {
}, },
getDomElementForPopover(isNew, route) { getDomElementForPopover(isNew, route) {
let matchingDomObject let matchingDomObject
if (this.isWidget) {
const objectId = this.widgetEventDetails.object
const recurrenceId = this.widgetEventDetails.recurrenceId
if (isNew) { matchingDomObject = this.widgetRef.querySelector(`.fc-event[data-object-id="${objectId}"][data-recurrence-id="${recurrenceId}"]`)
this.placement = 'auto'
} else if (isNew) {
matchingDomObject = document.querySelector('.fc-highlight') matchingDomObject = document.querySelector('.fc-highlight')
this.placement = 'auto' this.placement = 'auto'
@ -344,7 +365,7 @@ export default {
return matchingDomObject return matchingDomObject
}, },
repositionPopover() { repositionPopover() {
const isNew = this.$route.name === 'NewPopoverView' const isNew = this.isWidget ? false : this.$route.name === 'NewPopoverView'
this.$refs.popover.$children[0].$refs.reference = this.getDomElementForPopover(isNew, this.$route) this.$refs.popover.$children[0].$refs.reference = this.getDomElementForPopover(isNew, this.$route)
this.$refs.popover.$children[0].$refs.popper.dispose() this.$refs.popover.$children[0].$refs.popper.dispose()
this.$refs.popover.$children[0].$refs.popper.init() this.$refs.popover.$children[0].$refs.popper.init()

View File

@ -8,6 +8,9 @@ const BabelLoaderExcludeNodeModulesExcept = require('babel-loader-exclude-node-m
// Add dashboard entry // Add dashboard entry
webpackConfig.entry.dashboard = path.join(__dirname, 'src', 'dashboard.js') webpackConfig.entry.dashboard = path.join(__dirname, 'src', 'dashboard.js')
//Add reference entry
webpackConfig.entry['reference'] = path.join(__dirname, 'src', 'reference.js')
// Add appointments entries // Add appointments entries
webpackConfig.entry['appointments-booking'] = path.join(__dirname, 'src', 'appointments/main-booking.js') webpackConfig.entry['appointments-booking'] = path.join(__dirname, 'src', 'appointments/main-booking.js')
webpackConfig.entry['appointments-confirmation'] = path.join(__dirname, 'src', 'appointments/main-confirmation.js') webpackConfig.entry['appointments-confirmation'] = path.join(__dirname, 'src', 'appointments/main-confirmation.js')