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\Events\BeforeAppointmentBookedEvent;
use OCA\Calendar\Listener\AppointmentBookedListener;
use OCA\Calendar\Listener\CalendarReferenceListener;
use OCA\Calendar\Listener\UserDeletedListener;
use OCA\Calendar\Notification\Notifier;
use OCA\Calendar\Profile\AppointmentsAction;
use OCA\Calendar\Reference\ReferenceProvider;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Collaboration\Reference\RenderReferenceEvent;
use OCP\Dashboard\IAPIWidgetV2;
use OCP\User\Events\UserDeletedEvent;
use function method_exists;
@ -65,9 +68,11 @@ class Application extends App implements IBootstrap {
if (method_exists($context, 'registerProfileLinkAction')) {
$context->registerProfileLinkAction(AppointmentsAction::class);
}
$context->registerReferenceProvider(ReferenceProvider::class);
$context->registerEventListener(BeforeAppointmentBookedEvent::class, AppointmentBookedListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerEventListener(RenderReferenceEvent::class, CalendarReferenceListener::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>
<div class="datepicker-button-section">
<NcButton v-shortkey="previousShortKeyConf"
<NcButton v-if="!isWidget"
v-shortkey="previousShortKeyConf"
:aria-label="previousLabel"
class="datepicker-button-section__previous button"
:name="previousLabel"
@ -32,20 +33,23 @@
<ChevronLeftIcon :size="22" />
</template>
</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"
@mousedown.stop.prevent="doNothing"
@mouseup.stop.prevent="doNothing">
{{ selectedDate | formatDateRange(view, locale) }}
</NcButton>
<DatePicker ref="datepicker"
class="datepicker-button-section__datepicker"
:class="isWidget ? 'datepicker-widget':'datepicker-button-section__datepicker'"
:append-to-body="isWidget"
:date="selectedDate"
:is-all-day="true"
:open.sync="isDatepickerOpen"
:type="view === 'multiMonthYear' ? 'year' : 'date'"
@change="navigateToDate" />
<NcButton v-shortkey="nextShortKeyConf"
<NcButton v-if="!isWidget"
v-shortkey="nextShortKeyConf"
:aria-label="nextLabel"
class="datepicker-button-section__next button"
:name="nextLabel"
@ -82,6 +86,12 @@ export default {
filters: {
formatDateRange,
},
props: {
isWidget: {
type: Boolean,
default: false,
},
},
data() {
return {
isDatepickerOpen: false,
@ -92,6 +102,9 @@ export default {
locale: (state) => state.settings.momentLocale,
}),
selectedDate() {
if (this.isWidget) {
return getDateFromFirstdayParam(this.$store.getters.widgetDate)
}
return getDateFromFirstdayParam(this.$route.params?.firstDay ?? 'now')
},
previousShortKeyConf() {
@ -139,6 +152,9 @@ export default {
}
},
view() {
if (this.isWidget) {
return this.$store.getters.widgetView
}
return this.$route.params.view
},
},
@ -190,17 +206,21 @@ export default {
this.navigateToDate(newDate)
},
navigateToDate(date) {
const name = this.$route.name
const params = Object.assign({}, this.$route.params, {
firstDay: getYYYYMMDDFromDate(date),
})
if (this.isWidget) {
this.$store.commit('setWidgetDate', { widgetDate: 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
if (this.$route.params.firstDay === getYYYYMMDDFromDate(date)) {
return
// Don't push new route when day didn't change
if (this.$route.params.firstDay === getYYYYMMDDFromDate(date)) {
return
}
this.$router.push({ name, params })
}
this.$router.push({ name, params })
},
toggleDatepicker() {
this.isDatepickerOpen = !this.isDatepickerOpen
@ -212,3 +232,9 @@ export default {
},
}
</script>
<style lang="scss">
.datepicker-widget{
width: 135px;
margin: 2px 5px 5px 5px;
}
</style>

View File

@ -59,6 +59,12 @@ export default {
components: {
NcButton,
},
props: {
isWidget: {
type: Boolean,
default: false,
},
},
computed: {
isAgendaDayViewSelected() {
return this.selectedView === 'timeGridDay'
@ -76,22 +82,30 @@ export default {
return this.selectedView === 'listMonth'
},
selectedView() {
if (this.isWidget) {
return this.$store.getters.widgetView
}
return this.$route.params.view
},
},
methods: {
view(viewName) {
const name = this.$route.name
const params = Object.assign({}, this.$route.params, {
view: viewName,
})
if (this.isWidget) {
this.$store.commit('setWidgetView', { 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>
<header id="embed-header" role="banner">
<div class="embed-header__date-section">
<AppNavigationHeaderDatePicker />
<AppNavigationHeaderTodayButton />
<header :id="isWidget? 'widget-header' :'embed-header'" role="banner">
<div :class="isWidget?'widget-header__date-section' :'embed-header__date-section'">
<AppNavigationHeaderDatePicker :is-widget="isWidget" />
<AppNavigationHeaderTodayButton v-if="!isWidget" />
</div>
<div class="embed-header__views-section">
<AppNavigationHeaderViewButtons />
<div :class="isWidget?'widget-header__views-section' :'embed-header__views-section'">
<AppNavigationHeaderViewButtons :is-widget="isWidget" />
</div>
<!-- TODO have one button per calendar -->
<div class="embed-header__share-section">
<div v-if="!isWidget" class="widget-header__share-section">
<Actions>
<template #icon>
<Download :size="20" decorative />
@ -74,6 +74,12 @@ export default {
CalendarBlank,
Download,
},
props: {
isWidget: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
subscriptions: 'sortedSubscriptions',
@ -98,3 +104,34 @@ export default {
},
}
</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>
<FullCalendar ref="fullCalendar"
:class="isWidget? 'fullcalendar-widget': ''"
:options="options" />
</template>
@ -72,6 +73,10 @@ export default {
FullCalendar,
},
props: {
isWidget: {
type: Boolean,
default: false,
},
/**
* Whether or not the user is authenticated
*/
@ -104,8 +109,8 @@ export default {
options() {
return {
// Initialization:
initialDate: getYYYYMMDDFromFirstdayParam(this.$route.params.firstDay),
initialView: this.$route.params.view,
initialDate: getYYYYMMDDFromFirstdayParam(this.$route?.params?.firstDay ?? 'now'),
initialView: this.$route?.params.view ?? 'dayGridMonth',
// Data
eventSources: this.eventSources,
// Plugins
@ -114,12 +119,12 @@ export default {
editable: this.isEditable,
selectable: this.isAuthenticatedUser,
eventAllow,
eventClick: eventClick(this.$store, this.$router, this.$route, window),
eventDrop: (...args) => eventDrop(this.$store, this.$refs.fullCalendar.getApi())(...args),
eventResize: eventResize(this.$store),
navLinkDayClick: navLinkDayClick(this.$router, this.$route),
navLinkWeekClick: navLinkWeekClick(this.$router, this.$route),
select: select(this.$store, this.$router, this.$route, window),
eventClick: eventClick(this.$store, this.$router, this.$route, window, this.isWidget, this.$refs.fullCalendar),
eventDrop: this.isWidget ? false : (...args) => eventDrop(this.$store, this.$refs.fullCalendar.getApi())(...args),
eventResize: this.isWidget ? false : eventResize(this.$store),
navLinkDayClick: this.isWidget ? false : navLinkDayClick(this.$router, this.$route),
navLinkWeekClick: this.isWidget ? false : navLinkWeekClick(this.$router, this.$route),
select: this.isWidget ? false : select(this.$store, this.$router, this.$route, window),
navLinks: true,
// Localization
...getDateFormattingConfig(),
@ -151,6 +156,12 @@ export default {
eventSources() {
return this.$store.getters.enabledCalendars.map(eventSource(this.$store))
},
widgetView() {
return this.$store.getters.widgetView
},
widgetDate() {
return this.$store.getters.widgetDate
},
/**
* FullCalendar Plugins
*
@ -170,11 +181,19 @@ export default {
isEditable() {
// We do not allow drag and drop when the editor is open.
return this.isAuthenticatedUser
&& this.$route.name !== 'EditPopoverView'
&& this.$route.name !== 'EditSidebarView'
&& this.$route?.name !== 'EditPopoverView'
&& this.$route?.name !== 'EditSidebarView'
},
},
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() {
const calendarApi = this.$refs.fullCalendar.getApi()
calendarApi.refetchEvents()
@ -226,40 +245,42 @@ export default {
* This view is not used as a router view,
* hence we can't use beforeRouteUpdate directly.
*/
this.$router.beforeEach((to, from, next) => {
if (to.params.firstDay !== from.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()
calendarApi.changeView(to.params.view)
this.saveNewView(to.params.view)
}
if (!this.isWidget) {
this.$router.beforeEach((to, from, next) => {
if (to.params.firstDay !== from.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()
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 !== 'NewSidebarView') {
const calendarApi = this.$refs.fullCalendar.getApi()
calendarApi.unselect()
}
const calendarApi = this.$refs.fullCalendar.getApi()
calendarApi.unselect()
}
next()
})
next()
})
// 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.
await this.$nextTick()
if (['NewPopoverView', 'NewSidebarView'].includes(this.$route.name)) {
const start = new Date(parseInt(this.$route.params.dtstart) * 1000)
const end = new Date(parseInt(this.$route.params.dtend) * 1000)
if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
const calendarApi = this.$refs.fullCalendar.getApi()
calendarApi.select({
start,
end,
allDay: this.$route.params.allDay === '1',
})
// 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.
await this.$nextTick()
if (['NewPopoverView', 'NewSidebarView'].includes(this.$route.name)) {
const start = new Date(parseInt(this.$route.params.dtstart) * 1000)
const end = new Date(parseInt(this.$route.params.dtend) * 1000)
if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
const calendarApi = this.$refs.fullCalendar.getApi()
calendarApi.select({
start,
end,
allDay: this.$route.params.allDay === '1',
})
}
}
}
},
@ -277,7 +298,7 @@ export default {
}
</script>
<style lang="scss">
<style scoped lang="scss">
.calendar-grid-checkbox {
border-style: solid;
border-width: 2px;
@ -293,4 +314,10 @@ export default {
height: 16px;
width: 16px;
}
.fullcalendar-widget{
min-height: 500px;
:deep(.fc-col-header-cell-cushion){
font-size: 9px;
}
}
</style>

View File

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

View File

@ -41,6 +41,13 @@ import { showError } from '@nextcloud/dialogs'
* See inline for more documentation
*/
export default {
props: {
// Whether or not the calendar is embedded in a widget
isWidget: {
type: Boolean,
default: false,
},
},
data() {
return {
// 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
*/
closeEditor() {
if (this.isWidget) {
this.$store.commit('closeWidgetEventDetails')
return
}
const params = Object.assign({}, this.$store.state.route.params)
delete params.object
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: {},
initialCalendarsLoaded: false,
editCalendarModal: undefined,
widgetView: 'dayGridMonth',
widgetDate: 'now',
widgetEventDetailsOpen: false,
widgetEventDetails: {},
widgetRef: undefined,
}
const mutations = {
@ -83,6 +88,30 @@ const mutations = {
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 }) {
state.scheduleInbox = scheduleInbox
},
@ -444,6 +473,22 @@ const getters = {
.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) {
return state.trashBin !== undefined && state.trashBin.retentionDuration !== 0
},

View File

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

View File

@ -23,7 +23,7 @@
<template>
<Popover ref="popover"
:shown="isVisible"
:shown="showPopover"
:auto-hide="false"
:placement="placement"
:boundary="boundaryElement"
@ -148,7 +148,8 @@
:calendar-id="calendarId"
@close="closeEditorAndSkipAction" />
<SaveButtons class="event-popover__buttons"
<SaveButtons v-if="!isWidget"
class="event-popover__buttons"
:can-create-recurrence-exception="canCreateRecurrenceException"
:is-new="isNew"
:is-read-only="isReadOnlyOrViewing"
@ -236,7 +237,7 @@ export default {
placement: 'auto',
hasLocation: false,
hasDescription: false,
boundaryElement: document.querySelector('#app-content-vue > .fc'),
boundaryElement: null,
isVisible: true,
isViewing: true,
}
@ -244,15 +245,22 @@ export default {
computed: {
...mapState({
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
*
* @return {boolean}
*/
isReadOnlyOrViewing() {
return this.isReadOnly || this.isViewing
return this.isReadOnly || this.isViewing || this.isWidget
},
},
watch: {
@ -260,7 +268,7 @@ export default {
this.repositionPopover()
// 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() {
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.keyboardSaveEvent)
window.addEventListener('keydown', this.keyboardDeleteEvent)
@ -314,8 +330,13 @@ export default {
},
getDomElementForPopover(isNew, route) {
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')
this.placement = 'auto'
@ -344,7 +365,7 @@ export default {
return matchingDomObject
},
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.popper.dispose()
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
webpackConfig.entry.dashboard = path.join(__dirname, 'src', 'dashboard.js')
//Add reference entry
webpackConfig.entry['reference'] = path.join(__dirname, 'src', 'reference.js')
// Add appointments entries
webpackConfig.entry['appointments-booking'] = path.join(__dirname, 'src', 'appointments/main-booking.js')
webpackConfig.entry['appointments-confirmation'] = path.join(__dirname, 'src', 'appointments/main-confirmation.js')