Implement Calendar Dashboard Widget

Signed-off-by: Julius Härtl <jus@bitgrid.net>
This commit is contained in:
Julius Härtl 2020-07-10 16:01:27 +02:00 committed by Georg Ehrke
parent 25e852346e
commit eb1c1aab7b
No known key found for this signature in database
GPG Key ID: 9D98FD9380A1CB43
11 changed files with 763 additions and 5 deletions

View File

@ -12,7 +12,7 @@ jobs:
strategy:
matrix:
php-versions: ['7.2', '7.3', '7.4']
nextcloud-versions: ['master', 'stable17', 'stable18']
nextcloud-versions: ['master']
exclude:
- php-versions: '7.4'
nextcloud-versions: 'stable17'

View File

@ -23,19 +23,39 @@ declare(strict_types=1);
*/
namespace OCA\Calendar\AppInfo;
use OCA\Calendar\Dashboard\CalendarWidget;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
/**
* Class Application
*
* @package OCA\Calendar\AppInfo
*/
class Application extends App {
class Application extends App implements IBootstrap {
/** @var string */
public const APP_ID = 'calendar';
/**
* @param array $params
*/
public function __construct(array $params=[]) {
parent::__construct('calendar', $params);
parent::__construct(self::APP_ID, $params);
}
/**
* @inheritDoc
*/
public function register(IRegistrationContext $context): void {
$context->registerDashboardWidget(CalendarWidget::class);
}
/**
* @inheritDoc
*/
public function boot(IBootContext $context): void {
}
}

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @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\Dashboard;
use OCA\Calendar\AppInfo\Application;
use OCA\Calendar\Service\JSDataService;
use OCP\Dashboard\IWidget;
use OCP\IInitialStateService;
use OCP\IL10N;
class CalendarWidget implements IWidget {
/**
* @var IL10N
*/
private $l10n;
/**
* @var IInitialStateService
*/
private $initialStateService;
/**
* @var JSDataService
*/
private $dataService;
/**
* CalendarWidget constructor.
* @param IL10N $l10n
* @param IInitialStateService $initialStateService
* @param JSDataService $dataService
*/
public function __construct(IL10N $l10n,
IInitialStateService $initialStateService,
JSDataService $dataService) {
$this->l10n = $l10n;
$this->initialStateService = $initialStateService;
$this->dataService = $dataService;
}
/**
* @inheritDoc
*/
public function getId(): string {
return Application::APP_ID;
}
/**
* @inheritDoc
*/
public function getTitle(): string {
return $this->l10n->t('Upcoming events');
}
/**
* @inheritDoc
*/
public function getOrder(): int {
return 2;
}
/**
* @inheritDoc
*/
public function getIconClass(): string {
return 'icon-calendar-dark';
}
/**
* @inheritDoc
*/
public function getUrl(): ?string {
return null;
}
/**
* @inheritDoc
*/
public function load(): void {
\OCP\Util::addScript('calendar', 'dashboard');
$this->initialStateService->provideLazyInitialState(Application::APP_ID, 'dashboard_data', function () {
return $this->dataService;
});
}
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* Calendar App
*
* @author Georg Ehrke
* @copyright 2020 Georg Ehrke <oc.list@georgehrke.com>
*
* 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/>.
*
*/
namespace OCA\Calendar\Service;
use OCA\Calendar\AppInfo\Application;
use OCP\IConfig;
use OCP\IUserSession;
class JSDataService implements \JsonSerializable {
/** @var IConfig */
private $config;
/** @var IUserSession */
private $userSession;
/**
* JSDataService constructor.
*
* @param IConfig $config
* @param IUserSession $userSession
*/
public function __construct(IConfig $config,
IUserSession $userSession) {
$this->config = $config;
$this->userSession = $userSession;
}
/**
* @inheritDoc
*/
public function jsonSerialize() {
$user = $this->userSession->getUser();
if ($user === null) {
return [];
}
$defaultTimezone = $this->config->getAppValue(Application::APP_ID, 'timezone', 'automatic');
$defaultShowTasks = $this->config->getAppValue(Application::APP_ID, 'showTasks', 'yes');
$timezone = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'timezone', $defaultTimezone);
$showTasks = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'showTasks', $defaultShowTasks) === 'yes';
return [
'timezone' => $timezone,
'show_tasks' => $showTasks,
];
}
}

10
package-lock.json generated
View File

@ -1933,6 +1933,16 @@
"vue2-datepicker": "^3.6.2"
}
},
"@nextcloud/vue-dashboard": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@nextcloud/vue-dashboard/-/vue-dashboard-0.1.3.tgz",
"integrity": "sha512-7b02zkarX7b18IRQmZEW1NM+dvtcUih2M0+CZyuQfcvfyMQudOz+BdA/oD1p7PmdBds1IR8OvY1+CnpmgAzfQg==",
"requires": {
"@nextcloud/vue": "^2.3.0",
"core-js": "^3.6.4",
"vue": "^2.6.11"
}
},
"@nodelib/fs.scandir": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz",

View File

@ -51,6 +51,7 @@
"@nextcloud/moment": "^1.1.0",
"@nextcloud/router": "^1.1.0",
"@nextcloud/vue": "^2.6.0",
"@nextcloud/vue-dashboard": "^0.1.3",
"autosize": "^4.0.2",
"calendar-js": "git+https://github.com/nextcloud/calendar-js.git",
"cdav-library": "github:nextcloud/cdav-library",

50
src/dashboard.js Normal file
View File

@ -0,0 +1,50 @@
/*
* @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @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 Vue from 'vue'
import { generateFilePath } from '@nextcloud/router'
import { getRequestToken } from '@nextcloud/auth'
import { translate, translatePlural } from '@nextcloud/l10n'
import Dashboard from './views/Dashboard'
import store from './store'
// eslint-disable-next-line
__webpack_nonce__ = btoa(getRequestToken())
// eslint-disable-next-line
__webpack_public_path__ = generateFilePath('calendar', '', 'js/')
Vue.prototype.t = translate
Vue.prototype.n = translatePlural
Vue.prototype.OC = OC
Vue.prototype.OCA = OCA
document.addEventListener('DOMContentLoaded', function() {
OCA.Dashboard.register('calendar', (el) => {
const View = Vue.extend(Dashboard)
new View({
store,
propsData: {},
}).$mount(el)
})
})

312
src/views/Dashboard.vue Normal file
View File

@ -0,0 +1,312 @@
<!--
- @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
-
- @author Julius Härtl <jus@bitgrid.net>
-
- @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>
<DashboardWidget
id="calendar_panel"
:items="items"
:loading="loading">
<template #default="{ item }">
<EmptyContent v-if="item.isEmptyItem"
id="calendar-widget-empty-content"
class="half-screen"
icon="icon-checkmark">
<template #desc>
{{ t('calendar', 'No more events today') }}
</template>
</EmptyContent>
<DashboardWidgetItem v-else :item="item">
<template #avatar>
<div
v-if="item.componentName === 'VEVENT'"
class="calendar-dot"
:style="{'background-color': item.calendarColor}"
:title="item.calendarDisplayName" />
<div v-else
class="vtodo-checkbox"
:style="{'color': item.calendarColor}"
:title="item.calendarDisplayName" />
</template>
</DashboardWidgetItem>
</template>
<template #empty-content>
<EmptyContent
id="calendar-widget-empty-content"
icon="icon-calendar-dark">
<template #desc>
<p class="empty-label">
{{ t('calendar', 'No upcoming events') }}
</p>
<p>
<a
class="button"
:href="clickStartNew">
{{ t('calendar', 'Create a new event') }}
</a>
</p>
</template>
</EmptyContent>
</template>
</DashboardWidget>
</template>
<script>
import { DashboardWidget, DashboardWidgetItem } from '@nextcloud/vue-dashboard'
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
import { loadState } from '@nextcloud/initial-state'
import moment from '@nextcloud/moment'
import { imagePath, generateUrl } from '@nextcloud/router'
import { initializeClientForUserView } from '../services/caldavService'
import { dateFactory } from '../utils/date'
import pLimit from 'p-limit'
import { eventSourceFunction } from '../fullcalendar/eventSources/eventSourceFunction'
import getTimezoneManager from '../services/timezoneDataProviderService'
import loadMomentLocalization from '../utils/moment.js'
export default {
name: 'Dashboard',
components: {
DashboardWidget,
DashboardWidgetItem,
EmptyContent,
},
data() {
return {
events: null,
locale: 'en',
imagePath: imagePath('calendar', 'illustrations/calendar'),
loading: true,
now: dateFactory(),
}
},
computed: {
/**
* Format loaded events
*
* @returns {Array}
*/
items() {
if (!Array.isArray(this.events) || this.events.length === 0) {
return []
}
const firstEvent = this.events[0]
const endOfToday = moment(this.now).endOf('day')
if (endOfToday.isBefore(firstEvent.startDate)) {
return [{
isEmptyItem: true,
}].concat(this.events.slice(0, 4))
}
return this.events
},
/**
* Redirects to the new event route
* @returns {String}
*/
clickStartNew() {
return generateUrl('apps/calendar') + '/new'
},
},
mounted() {
this.initialize()
},
methods: {
/**
* Initialize the widget
*/
async initialize() {
const start = dateFactory()
const end = dateFactory()
end.setDate(end.getDate() + 14)
await this.initializeEnvironment()
const expandedEvents = await this.fetchExpandedEvents(start, end)
this.events = await this.formatEvents(expandedEvents)
this.loading = false
},
/**
* Initialize everything necessary,
* before we can fetch events
*
* @returns {Promise<void>}
*/
async initializeEnvironment() {
await initializeClientForUserView()
await this.$store.dispatch('fetchCurrentUserPrincipal')
await this.$store.dispatch('getCalendars')
const {
show_tasks: showTasks,
timezone,
} = loadState('calendar', 'dashboard_data')
const locale = await loadMomentLocalization()
this.$store.commit('loadSettingsFromServer', {
timezone,
showTasks,
})
this.$store.commit('setMomentLocale', {
locale,
})
},
/**
* Fetch events
*
* @param {Date} from Start of time-range
* @param {Date} to End of time-range
*
* @returns {Promise<Object[]>}
*/
async fetchExpandedEvents(from, to) {
const timeZone = this.$store.getters.getResolvedTimezone
let timezoneObject = getTimezoneManager().getTimezoneForId(timeZone)
if (!timezoneObject) {
timezoneObject = getTimezoneManager().getTimezoneForId('UTC')
}
const limit = pLimit(10)
const fetchEventPromises = []
for (const calendar of this.$store.getters.enabledCalendars) {
fetchEventPromises.push(limit(async() => {
let timeRangeId
try {
timeRangeId = await this.$store.dispatch('getEventsFromCalendarInTimeRange', {
calendar,
from,
to,
})
} catch (e) {
return []
}
const calendarObjects = this.$store.getters.getCalendarObjectsByTimeRangeId(timeRangeId)
return eventSourceFunction(calendarObjects, calendar, from, to, timezoneObject)
}))
}
const expandedEvents = await Promise.all(fetchEventPromises)
return expandedEvents.flat()
},
/**
* @param {Object[]} expandedEvents Array of fullcalendar events
* @returns {Object[]}
*/
formatEvents(expandedEvents) {
return expandedEvents
.sort((a, b) => a.start.getTime() - b.start.getTime())
.filter(event => !event.classNames.includes('fc-event-nc-task-completed'))
.slice(0, 7)
.map((event) => ({
isEmptyItem: false,
componentName: event.extendedProps.objectType,
targetUrl: event.extendedProps.objectType === 'VEVENT'
? this.getCalendarAppUrl(event)
: this.getTasksAppUrl(event),
subText: this.formatSubtext(event),
mainText: event.title,
startDate: event.start,
calendarColor: this.$store.state.calendars.calendarsById[event.extendedProps.calendarId].color,
calendarDisplayName: this.$store.state.calendars.calendarsById[event.extendedProps.calendarId].displayname,
}))
},
/**
* @param {Object} event The full-calendar formatted event
* @returns {String}
*/
formatSubtext(event) {
const locale = this.$store.state.settings.momentLocale
if (event.allDay) {
return moment(event.start).locale(locale).calendar(null, {
// TRANSLATORS Please translate only the text in brackets and keep the brackets!
sameDay: t('calendar', '[Today]'),
// TRANSLATORS Please translate only the text in brackets and keep the brackets!
nextDay: t('calendar', '[Tomorrow]'),
nextWeek: 'dddd',
// TRANSLATORS Please translate only the text in brackets and keep the brackets!
lastDay: t('calendar', '[Yesterday]'),
// TRANSLATORS Please translate only the text in brackets and keep the brackets!
lastWeek: t('calendar', '[Last] dddd'),
sameElse: () => '[replace-from-now]',
}).replace('replace-from-now', moment(event.start).locale(locale).fromNow())
} else {
return moment(event.start).locale(locale).calendar(null, {
sameElse: () => '[replace-from-now]',
}).replace('replace-from-now', moment(event.start).locale(locale).fromNow())
}
},
/**
* @param {Object} data The data destructuring object
* @param {Object} data.extendedProps Extended Properties of the FC object
* @returns {string}
*/
getCalendarAppUrl({ extendedProps }) {
return generateUrl('apps/calendar') + '/edit/' + extendedProps.objectId + '/' + extendedProps.recurrenceId
},
/**
* @param {Object} data The data destructuring object
* @param {Object} data.extendedProps Extended Properties of the FC object
* @returns {string}
*/
getTasksAppUrl({ extendedProps }) {
const davUrlParts = extendedProps.davUrl.split('/')
const taskId = davUrlParts.pop()
const calendarId = davUrlParts.pop()
return generateUrl('apps/tasks') + `/#/calendars/${calendarId}/tasks/${taskId}`
},
},
}
</script>
<style lang="scss">
@import '../fonts/scss/iconfont-calendar-app';
#calendar_panel {
.vtodo-checkbox {
border-color: transparent;
@include iconfont('checkbox');
}
.calendar-dot {
height: 1rem;
width: 1rem;
margin-top: 0.2rem;
border-radius: 50%;
}
#calendar-widget-empty-content {
text-align: center;
margin-top: 5vh;
&.half-screen {
margin-top: 0;
height: 120px;
margin-bottom: 2vh;
}
.empty-label {
margin-bottom: 20px;
}
}
}
</style>

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/**
* Calendar App
*
* @author Georg Ehrke
* @copyright 2020 Georg Ehrke <oc.list@georgehrke.com>
*
* 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/>.
*
*/
namespace OCA\Calendar\Dashboard;
use ChristophWurst\Nextcloud\Testing\TestCase;
use OCA\Calendar\Service\JSDataService;
use OCP\IInitialStateService;
use OCP\IL10N;
class CalendarWidgetTest extends TestCase {
/** @var IL10N|\PHPUnit\Framework\MockObject\MockObject */
private $l10n;
/** @var IInitialStateService|\PHPUnit\Framework\MockObject\MockObject */
private $initialState;
/** @var JSDataService|\PHPUnit\Framework\MockObject\MockObject */
private $service;
/** @var CalendarWidget */
private $widget;
protected function setUp(): void {
parent::setUp();
$this->l10n = $this->createMock(IL10N::class);
$this->initialState = $this->createMock(IInitialStateService::class);
$this->service = $this->createMock(JSDataService::class);
$this->widget = new CalendarWidget($this->l10n, $this->initialState, $this->service);
}
public function testGetId(): void {
$this->assertEquals('calendar', $this->widget->getId());
}
public function testGetTitle(): void {
$this->l10n->expects($this->exactly(1))
->method('t')
->willReturnArgument(0);
$this->assertEquals('Upcoming events', $this->widget->getTitle());
}
public function testGetOrder(): void {
$this->assertEquals(2, $this->widget->getOrder());
}
public function testGetIconClass(): void {
$this->assertEquals('icon-calendar-dark', $this->widget->getIconClass());
}
public function testGetUrl(): void {
$this->assertNull($this->widget->getUrl());
}
public function testLoad(): void {
$this->initialState->expects($this->once())
->method('provideLazyInitialState')
->with('calendar', 'dashboard_data', $this->callback(function ($actual) {
$fnResult = $actual();
return $fnResult === $this->service;
}));
$this->widget->load();
}
}

View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/**
* Calendar App
*
* @author Georg Ehrke
* @copyright 2020 Georg Ehrke <oc.list@georgehrke.com>
*
* 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/>.
*
*/
namespace OCA\Calendar\Service;
use ChristophWurst\Nextcloud\Testing\TestCase;
use OCP\IConfig;
use OCP\IUser;
use OCP\IUserSession;
class JsDataServiceTest extends TestCase {
/** @var IConfig|\PHPUnit\Framework\MockObject\MockObject */
private $config;
/** @var IUserSession|\PHPUnit\Framework\MockObject\MockObject */
private $userSession;
/** @var JSDataService */
private $service;
protected function setUp(): void {
parent::setUp();
$this->config = $this->createMock(IConfig::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->service = new JSDataService($this->config, $this->userSession);
}
public function testJsonSerialize(): void {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('john.doe');
$this->userSession->expects($this->once())
->method('getUser')
->willReturn($user);
$this->config->expects($this->at(0))
->method('getAppValue')
->with('calendar', 'timezone', 'automatic')
->willReturn('default-app-value-timezone');
$this->config->expects($this->at(1))
->method('getAppValue')
->with('calendar', 'showTasks', 'yes')
->willReturn('default-app-value-showTasks');
$this->config->expects($this->at(2))
->method('getUserValue')
->with('john.doe', 'calendar', 'timezone', 'default-app-value-timezone')
->willReturn('timezone-config-value');
$this->config->expects($this->at(3))
->method('getUserValue')
->with('john.doe', 'calendar', 'showTasks', 'default-app-value-showTasks')
->willReturn('yes');
$this->assertEquals([
'timezone' => 'timezone-config-value',
'show_tasks' => true,
], $this->service->jsonSerialize());
}
public function testJsonSerializeNoUserSession(): void {
$this->userSession->expects($this->once())
->method('getUser')
->willReturn(null);
$this->config->expects($this->never())
->method('getAppValue');
$this->config->expects($this->never())
->method('getUserValue');
$this->assertEmpty($this->service->jsonSerialize());
}
}

View File

@ -12,11 +12,14 @@ const SCOPE_VERSION = JSON.stringify(versionHash)
const ICONFONT_NAME = `iconfont-calendar-app-${versionHash}`
module.exports = {
entry: path.join(__dirname, 'src', 'main.js'),
entry: {
calendar: path.join(__dirname, 'src', 'main.js'),
dashboard: path.join(__dirname, 'src', 'dashboard.js'),
},
output: {
path: path.resolve(__dirname, './js'),
publicPath: '/js/',
filename: 'calendar.js',
filename: '[name].js',
chunkFilename: 'chunks/calendar.[name].[contenthash].js'
},
module: {