MOBILE-1927 calendar: Allow creating events in online

main
Dani Palou 2019-06-20 16:15:05 +02:00
parent f7ea003b41
commit cfaaefc184
12 changed files with 992 additions and 13 deletions

View File

@ -3,13 +3,26 @@
"calendarevents": "Calendar events",
"calendarreminders": "Calendar reminders",
"defaultnotificationtime": "Default notification time",
"durationminutes": "Duration in minutes",
"durationnone": "Without duration",
"durationuntil": "Until",
"editevent": "Editing event",
"errorloadevent": "Error loading event.",
"errorloadevents": "Error loading events.",
"eventduration": "Duration",
"eventendtime": "End time",
"eventname": "Event title",
"eventstarttime": "Start time",
"eventtype": "Event type",
"gotoactivity": "Go to activity",
"invalidtimedurationminutes": "The duration in minutes you have entered is invalid. Please enter the duration in minutes greater than 0 or select no duration.",
"invalidtimedurationuntil": "The date and time you selected for duration until is before the start time of the event. Please correct this before proceeding.",
"newevent": "New event",
"noevents": "There are no events",
"nopermissiontoupdatecalendar": "Sorry, but you do not have permission to update the calendar event",
"reminders": "Reminders",
"repeatevent": "Repeat this event",
"repeatweeksl": "Repeat weekly, creating altogether",
"setnewreminder": "Set a new reminder",
"typeclose": "Close event",
"typecourse": "Course event",

View File

@ -0,0 +1,144 @@
<ion-header>
<ion-navbar core-back-button>
<ion-title><core-format-text [text]="title | translate"></core-format-text></ion-title>
</ion-navbar>
</ion-header>
<ion-content>
<ion-refresher [enabled]="loaded" (ionRefresh)="refreshData($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<form ion-list [formGroup]="eventForm">
<!-- Event name. -->
<ion-item text-wrap>
<ion-label stacked><h2 [core-mark-required]="true">{{ 'addon.calendar.eventname' | translate }}</h2></ion-label>
<ion-input type="text" name="name" [placeholder]="'addon.calendar.eventname' | translate" [formControlName]="'name'"></ion-input>
<core-input-errors item-content [control]="eventForm.controls.name" [errorMessages]="errors"></core-input-errors>
</ion-item>
<!-- Date. -->
<ion-item text-wrap>
<ion-label stacked><h2 [core-mark-required]="true">{{ 'core.date' | translate }}</h2></ion-label>
<ion-datetime [formControlName]="'timestart'" [placeholder]="'core.date' | translate" [displayFormat]="dateFormat"></ion-datetime>
<core-input-errors item-content [control]="eventForm.controls.timestart" [errorMessages]="errors"></core-input-errors>
</ion-item>
<!-- Type. -->
<ion-item text-wrap class="addon-calendar-eventtype-container">
<ion-label id="addon-calendar-eventtype-label"><h2 [core-mark-required]="true">{{ 'addon.calendar.eventtype' | translate }}</h2></ion-label>
<ion-select [formControlName]="'eventtype'" aria-labelledby="addon-calendar-eventtype-label" interface="action-sheet" [disabled]="eventTypes.length == 1">
<ion-option *ngFor="let type of eventTypes" [value]="type.value">{{ type.name | translate }}</ion-option>
</ion-select>
</ion-item>
<!-- Category. -->
<ion-item text-wrap *ngIf="eventTypeControl.value == 'category'">
<ion-label id="addon-calendar-category-label"><h2 [core-mark-required]="true">{{ 'core.category' | translate }}</h2></ion-label>
<ion-select [formControlName]="'categoryid'" aria-labelledby="addon-calendar-category-label" interface="action-sheet" [placeholder]="'core.noselection' | translate">
<ion-option *ngFor="let category of categories" [value]="category.id">{{ category.name }}</ion-option>
</ion-select>
</ion-item>
<!-- Course. -->
<ion-item text-wrap *ngIf="eventTypeControl.value == 'course'">
<ion-label id="addon-calendar-course-label"><h2 [core-mark-required]="true">{{ 'core.course' | translate }}</h2></ion-label>
<ion-select [formControlName]="'courseid'" aria-labelledby="addon-calendar-course-label" interface="action-sheet" [placeholder]="'core.noselection' | translate">
<ion-option *ngFor="let course of courses" [value]="course.id">{{ course.fullname }}</ion-option>
</ion-select>
</ion-item>
<!-- Group. -->
<ng-container *ngIf="eventTypeControl.value == 'group'">
<!-- Select the course. -->
<ion-item text-wrap>
<ion-label id="addon-calendar-groupcourse-label"><h2 [core-mark-required]="true">{{ 'core.course' | translate }}</h2></ion-label>
<ion-select [formControlName]="'groupcourseid'" aria-labelledby="addon-calendar-groupcourse-label" interface="action-sheet" [placeholder]="'core.noselection' | translate" (ionChange)="groupCourseSelected($event)">
<ion-option *ngFor="let course of courses" [value]="course.id">{{ course.fullname }}</ion-option>
</ion-select>
</ion-item>
<!-- The course has no groups. -->
<ion-item text-wrap *ngIf="!loadingGroups && courseGroupSet && !groups.length" class="core-danger-item">
<p>{{ 'core.coursenogroups' | translate }}</p>
</ion-item>
<!-- Select the group. -->
<ion-item text-wrap *ngIf="!loadingGroups && groups.length > 0">
<ion-label id="addon-calendar-group-label"><h2 [core-mark-required]="true">{{ 'core.group' | translate }}</h2></ion-label>
<ion-select [formControlName]="'groupid'" aria-labelledby="addon-calendar-group-label" interface="action-sheet" [placeholder]="'core.noselection' | translate">
<ion-option *ngFor="let group of groups" [value]="group.id">{{ group.name }}</ion-option>
</ion-select>
</ion-item>
<!-- Loading groups. -->
<ion-item text-wrap *ngIf="loadingGroups">
<ion-spinner *ngIf="loadingGroups"></ion-spinner>
</ion-item>
</ng-container>
<!-- Advanced options. -->
<ion-item-divider text-wrap (click)="toggleAdvanced()" class="core-expandable">
<core-icon *ngIf="!advanced" name="fa-caret-right" item-start></core-icon>
<span *ngIf="!advanced">{{ 'core.showmore' | translate }}</span>
<core-icon *ngIf="advanced" name="fa-caret-down" item-start></core-icon>
<span *ngIf="advanced">{{ 'core.showless' | translate }}</span>
</ion-item-divider>
<ng-container *ngIf="advanced">
<!-- Description. -->
<ion-item text-wrap>
<ion-label stacked><h2>{{ 'core.description' | translate }}</h2></ion-label>
<core-rich-text-editor item-content [control]="descriptionControl" [placeholder]="'core.description' | translate" name="description" [component]="component" [componentId]="eventId"></core-rich-text-editor>
</ion-item>
<!-- Location. -->
<ion-item text-wrap>
<ion-label stacked><h2>{{ 'core.location' | translate }}</h2></ion-label>
<ion-input type="text" name="location" [placeholder]="'core.location' | translate" [formControlName]="'location'"></ion-input>
</ion-item>
<!-- Duration. -->
<div text-wrap radio-group [formControlName]="'duration'" class="addon-calendar-duration-container">
<ion-item class="addon-calendar-duration-title"><h2>{{ 'addon.calendar.eventduration' | translate }}</h2></ion-item>
<ion-item>
<ion-label>{{ 'addon.calendar.durationnone' | translate }}</ion-label>
<ion-radio [value]="0"></ion-radio>
</ion-item>
<ion-item>
<ion-label>{{ 'addon.calendar.durationuntil' | translate }}</ion-label>
<ion-radio [value]="1"></ion-radio>
</ion-item>
<ion-item text-wrap>
<ion-datetime [formControlName]="'timedurationuntil'" [placeholder]="'addon.calendar.durationuntil' | translate" [displayFormat]="dateFormat" [disabled]="eventForm.controls.duration.value != 1"></ion-datetime>
</ion-item>
<ion-item>
<ion-label>{{ 'addon.calendar.durationminutes' | translate }}</ion-label>
<ion-radio [value]="2"></ion-radio>
</ion-item>
<ion-item text-wrap>
<ion-input type="number" name="timedurationminutes" [placeholder]="'addon.calendar.durationminutes' | translate" [formControlName]="'timedurationminutes'" [disabled]="eventForm.controls.duration.value != 2"></ion-input>
</ion-item>
</div>
<!-- Repeat. -->
<ion-item text-wrap>
<ion-label><h2>{{ 'addon.calendar.repeatevent' | translate }}</h2></ion-label>
<ion-checkbox item-end [formControlName]="'repeat'"></ion-checkbox>
</ion-item>
<ion-item text-wrap *ngIf="eventForm.controls.repeat.value">
<ion-label stacked><h2>{{ 'addon.calendar.repeatweeksl' | translate }}</h2></ion-label>
<ion-input type="number" name="repeats" [formControlName]="'repeats'"></ion-input>
</ion-item>
</ng-container>
<ion-item>
<ion-row>
<ion-col>
<button ion-button block (click)="submit()" [disabled]="!eventForm.valid">{{ 'core.save' | translate }}</button>
</ion-col>
<ion-col *ngIf="hasOffline">
<button ion-button block color="light" (click)="discard()">{{ 'core.discard' | translate }}</button>
</ion-col>
</ion-row>
</ion-item>
</form>
</core-loading>
</ion-content>

View File

@ -0,0 +1,33 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { AddonCalendarEditEventPage } from './edit-event';
@NgModule({
declarations: [
AddonCalendarEditEventPage,
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
IonicPageModule.forChild(AddonCalendarEditEventPage),
TranslateModule.forChild()
],
})
export class AddonCalendarEditEventPageModule {}

View File

@ -0,0 +1,35 @@
ion-app.app-root page-addon-calendar-edit-event {
.addon-calendar-duration-container ion-item:not(.addon-calendar-duration-title) {
&.item-ios {
@include padding-horizontal($item-ios-padding-start * 2, null);
ion-input {
@include padding-horizontal($datetime-ios-padding-start - $text-input-ios-margin-start, null);
}
}
&.item-md {
@include padding-horizontal($item-md-padding-start * 2, null);
ion-input {
@include padding-horizontal($datetime-md-padding-start - $text-input-md-margin-start, null);
}
}
&.item-wp {
@include padding-horizontal($item-wp-padding-start * 2, null);
ion-input {
@include padding-horizontal($datetime-wp-padding-start - $text-input-wp-margin-start, null);
}
}
}
.addon-calendar-eventtype-container.item-select-disabled {
ion-label, ion-select {
opacity: 1;
}
.select-icon {
display: none;
}
}
}

View File

@ -0,0 +1,392 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, Optional, ViewChild } from '@angular/core';
import { FormControl, FormGroup, FormBuilder, Validators } from '@angular/forms';
import { IonicPage, NavController, NavParams } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreEventsProvider } from '@providers/events';
import { CoreGroupsProvider } from '@providers/groups';
import { CoreSitesProvider } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreCoursesProvider } from '@core/courses/providers/courses';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreRichTextEditorComponent } from '@components/rich-text-editor/rich-text-editor.ts';
import { AddonCalendarProvider } from '../../providers/calendar';
import { AddonCalendarHelperProvider } from '../../providers/helper';
import { CoreSite } from '@classes/site';
/**
* Page that displays a form to create/edit an event.
*/
@IonicPage({ segment: 'addon-calendar-edit-event' })
@Component({
selector: 'page-addon-calendar-edit-event',
templateUrl: 'edit-event.html',
})
export class AddonCalendarEditEventPage implements OnInit {
@ViewChild(CoreRichTextEditorComponent) descriptionEditor: CoreRichTextEditorComponent;
title: string;
dateFormat: string;
component = AddonCalendarProvider.COMPONENT;
loaded = false;
hasOffline = false;
eventTypes = [];
categories = [];
courses = [];
groups = [];
loadingGroups = false;
courseGroupSet = false;
advanced = false;
errors: any;
// Form variables.
eventForm: FormGroup;
eventTypeControl: FormControl;
groupControl: FormControl;
descriptionControl: FormControl;
protected eventId: number;
protected courseId: number;
protected originalData: any;
protected currentSite: CoreSite;
protected types: any; // Object with the supported types.
protected showAll: boolean;
constructor(navParams: NavParams,
private navCtrl: NavController,
private translate: TranslateService,
private domUtils: CoreDomUtilsProvider,
private timeUtils: CoreTimeUtilsProvider,
private eventsProvider: CoreEventsProvider,
private groupsProvider: CoreGroupsProvider,
sitesProvider: CoreSitesProvider,
private coursesProvider: CoreCoursesProvider,
private utils: CoreUtilsProvider,
private calendarProvider: AddonCalendarProvider,
private calendarHelper: AddonCalendarHelperProvider,
private fb: FormBuilder,
@Optional() private svComponent: CoreSplitViewComponent) {
this.eventId = navParams.get('eventId');
this.courseId = navParams.get('courseId');
this.title = this.eventId ? 'addon.calendar.editevent' : 'addon.calendar.newevent';
this.currentSite = sitesProvider.getCurrentSite();
this.errors = {
required: this.translate.instant('core.required')
};
// Calculate format to use. ion-datetime doesn't support escaping characters ([]), so we remove them.
this.dateFormat = this.timeUtils.convertPHPToMoment(this.translate.instant('core.strftimedatetimeshort'))
.replace(/[\[\]]/g, '');
// Initialize form variables.
this.eventForm = new FormGroup({});
this.eventTypeControl = this.fb.control('', Validators.required);
this.groupControl = this.fb.control('');
this.descriptionControl = this.fb.control('');
this.eventForm.addControl('name', this.fb.control('', Validators.required));
this.eventForm.addControl('timestart', this.fb.control(new Date().toISOString(), Validators.required));
this.eventForm.addControl('eventtype', this.eventTypeControl);
this.eventForm.addControl('categoryid', this.fb.control(''));
this.eventForm.addControl('courseid', this.fb.control(this.courseId));
this.eventForm.addControl('groupcourseid', this.fb.control(''));
this.eventForm.addControl('groupid', this.groupControl);
this.eventForm.addControl('description', this.descriptionControl);
this.eventForm.addControl('location', this.fb.control(''));
this.eventForm.addControl('duration', this.fb.control(0));
this.eventForm.addControl('timedurationuntil', this.fb.control(new Date().toISOString()));
this.eventForm.addControl('timedurationminutes', this.fb.control(''));
this.eventForm.addControl('repeat', this.fb.control(false));
this.eventForm.addControl('repeats', this.fb.control('1'));
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.fetchData().finally(() => {
this.originalData = this.utils.clone(this.eventForm.value);
this.loaded = true;
});
}
/**
* Fetch the data needed to render the form.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchData(): Promise<any> {
let accessInfo;
// Get access info.
return this.calendarProvider.getAccessInformation().then((info) => {
accessInfo = info;
return this.calendarProvider.getAllowedEventTypes();
}).then((types) => {
this.types = types;
const promises = [],
eventTypes = this.calendarHelper.getEventTypeOptions(types);
if (!eventTypes.length) {
return Promise.reject(this.translate.instant('addon.calendar.nopermissiontoupdatecalendar'));
}
if (types.category) {
// Get the categories.
promises.push(this.coursesProvider.getCategories(0, true).then((cats) => {
this.categories = cats;
}));
}
this.showAll = this.utils.isTrueOrOne(this.currentSite.getStoredConfig('calendar_adminseesall')) &&
accessInfo.canmanageentries;
if (types.course || types.groups) {
// Get the courses.
const promise = this.showAll ? this.coursesProvider.getCoursesByField() : this.coursesProvider.getUserCourses();
promises.push(promise.then((courses) => {
if (this.showAll) {
// Remove site home from the list of courses.
const siteHomeId = this.currentSite.getSiteHomeId();
courses = courses.filter((course) => {
return course.id != siteHomeId;
});
}
// Sort courses by name.
this.courses = courses.sort((a, b) => {
const compareA = a.fullname.toLowerCase(),
compareB = b.fullname.toLowerCase();
return compareA.localeCompare(compareB);
});
}));
}
return Promise.all(promises).then(() => {
// Set event types. If course is allowed, select it first.
if (types.course) {
this.eventTypeControl.setValue(AddonCalendarProvider.TYPE_COURSE);
} else {
this.eventTypeControl.setValue(eventTypes[0].value);
}
this.eventTypes = eventTypes;
});
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error getting data.');
this.originalData = null; // Avoid asking for confirmation.
this.navCtrl.pop();
});
}
/**
* Pull to refresh.
*
* @param {any} refresher Refresher.
*/
refreshData(refresher: any): void {
const promises = [
this.calendarProvider.invalidateAccessInformation(this.courseId),
this.calendarProvider.invalidateAllowedEventTypes(this.courseId)
];
if (this.types) {
if (this.types.category) {
promises.push(this.coursesProvider.invalidateCategories(0, true));
}
if (this.types.course || this.types.groups) {
if (this.showAll) {
promises.push(this.coursesProvider.invalidateCoursesByField());
} else {
promises.push(this.coursesProvider.invalidateUserCourses());
}
}
}
Promise.all(promises).finally(() => {
this.fetchData().finally(() => {
refresher.complete();
});
});
}
/**
* A course was selected, get its groups.
*
* @param {number} courseId Course ID.
*/
groupCourseSelected(courseId: number): void {
if (!courseId) {
return;
}
const modal = this.domUtils.showModalLoading();
this.loadingGroups = true;
this.groupsProvider.getUserGroupsInCourse(courseId).then((groups) => {
this.groups = groups;
this.courseGroupSet = true;
this.groupControl.setValue('');
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error getting data.');
}).finally(() => {
this.loadingGroups = false;
modal.dismiss();
});
}
/**
* Show or hide advanced form fields.
*/
toggleAdvanced(): void {
this.advanced = !this.advanced;
}
/**
* Create the event.
*/
submit(): void {
// Validate data.
const formData = this.eventForm.value,
timeStartDate = new Date(formData.timestart),
timeUntilDate = new Date(formData.timedurationuntil),
timeDurationMinutes = parseInt(formData.timedurationminutes || '', 10);
let error;
if (formData.eventtype == AddonCalendarProvider.TYPE_COURSE && !formData.courseid) {
error = 'core.selectacourse';
} else if (formData.eventtype == AddonCalendarProvider.TYPE_GROUP && !formData.groupcourseid) {
error = 'core.selectacourse';
} else if (formData.eventtype == AddonCalendarProvider.TYPE_GROUP && !formData.groupid) {
error = 'core.selectagroup';
} else if (formData.eventtype == AddonCalendarProvider.TYPE_CATEGORY && !formData.categoryid) {
error = 'core.selectacategory';
} else if (formData.duration == 1 && timeStartDate.getTime() > timeUntilDate.getTime()) {
error = 'addon.calendar.invalidtimedurationuntil';
} else if (formData.duration == 2 && (isNaN(timeDurationMinutes) || timeDurationMinutes < 1)) {
error = 'addon.calendar.invalidtimedurationminutes';
}
if (error) {
// Show error and stop.
this.domUtils.showErrorModal(this.translate.instant(error));
return;
}
// Format the data to send.
const data: any = {
name: formData.name,
eventtype: formData.eventtype,
timestart: Math.floor(timeStartDate.getTime() / 1000),
description: {
text: formData.description,
format: 1
},
location: formData.location,
duration: formData.duration,
repeat: formData.repeat
};
if (formData.eventtype == AddonCalendarProvider.TYPE_COURSE) {
data.courseid = formData.courseid;
} else if (formData.eventtype == AddonCalendarProvider.TYPE_GROUP) {
data.groupcourseid = formData.groupcourseid;
data.groupid = formData.groupid;
} else if (formData.eventtype == AddonCalendarProvider.TYPE_CATEGORY) {
data.categoryid = formData.categoryid;
}
if (formData.duration == 1) {
data.timedurationuntil = Math.floor(timeUntilDate.getTime() / 1000);
} else if (formData.duration == 2) {
data.timedurationminutes = formData.timedurationminutes;
}
if (formData.repeat) {
data.repeats = formData.repeats;
}
// Send the data.
const modal = this.domUtils.showModalLoading('core.sending');
this.calendarProvider.submitEvent(this.eventId, data).then((event) => {
this.returnToList(event);
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error sending data.');
}).finally(() => {
modal.dismiss();
});
}
/**
* Convenience function to update or return to event list depending on device.
*
* @param {number} [event] Event.
*/
protected returnToList(event?: any): void {
const data: any = {
event: event
};
this.eventsProvider.trigger(AddonCalendarProvider.NEW_EVENT_EVENT, data, this.currentSite.getId());
if (this.svComponent && this.svComponent.isOn()) {
// Empty form.
this.hasOffline = false;
this.eventForm.reset(this.originalData);
this.originalData = this.utils.clone(this.eventForm.value);
} else {
this.originalData = null; // Avoid asking for confirmation.
this.navCtrl.pop();
}
}
/**
* Discard an offline saved discussion.
*/
discard(): void {
this.domUtils.showConfirm(this.translate.instant('core.areyousure')).then(() => {
// @todo.
}).catch(() => {
// Cancelled.
});
}
/**
* Check if we can leave the page or not.
*
* @return {boolean|Promise<void>} Resolved if we can leave it, rejected if not.
*/
ionViewCanLeave(): boolean | Promise<void> {
if (this.calendarHelper.hasEventDataChanged(this.eventForm.value, this.originalData)) {
// Show confirmation if some data has been modified.
return this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit'));
} else {
return Promise.resolve();
}
}
}

View File

@ -40,5 +40,12 @@
<core-infinite-loading [enabled]="canLoadMore" (action)="loadMoreEvents($event)" [error]="loadMoreError"></core-infinite-loading>
</core-loading>
<!-- Create a calendar event. -->
<ion-fab core-fab bottom end *ngIf="canCreate">
<button ion-fab (click)="openCreate()" [attr.aria-label]="'addon.calendar.newevent' | translate">
<ion-icon name="add"></ion-icon>
</button>
</ion-fab>
</ion-content>
</core-split-view>

View File

@ -54,6 +54,7 @@ export class AddonCalendarListPage implements OnDestroy {
protected obsDefaultTimeChange: any;
protected eventId: number;
protected preSelectedCourseId: number;
protected newEventObserver: any;
courses: any[];
eventsLoaded = false;
@ -65,6 +66,7 @@ export class AddonCalendarListPage implements OnDestroy {
filter = {
course: this.allCourses
};
canCreate = false;
constructor(private translate: TranslateService, private calendarProvider: AddonCalendarProvider, navParams: NavParams,
private domUtils: CoreDomUtilsProvider, private coursesProvider: CoreCoursesProvider, private utils: CoreUtilsProvider,
@ -74,6 +76,7 @@ export class AddonCalendarListPage implements OnDestroy {
this.siteHomeId = sitesProvider.getCurrentSite().getSiteHomeId();
this.notificationsEnabled = localNotificationsProvider.isAvailable();
if (this.notificationsEnabled) {
// Re-schedule events if default time changes.
this.obsDefaultTimeChange = eventsProvider.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => {
@ -83,6 +86,30 @@ export class AddonCalendarListPage implements OnDestroy {
this.eventId = navParams.get('eventId') || false;
this.preSelectedCourseId = navParams.get('courseId') || null;
// Listen for events added. When an event is added, we reload the data.
this.newEventObserver = eventsProvider.on(AddonCalendarProvider.NEW_EVENT_EVENT, (data) => {
if (data && data.event) {
if (this.splitviewCtrl.isOn()) {
// Discussion added, clear details page.
this.splitviewCtrl.emptyDetails();
}
this.eventsLoaded = false;
this.refreshEvents(false).finally(() => {
this.eventsLoaded = true;
// In tablet mode try to open the event.
if (this.splitviewCtrl.isOn()) {
if (data.event.id) {
this.gotoEvent(data.event.id);
} else {
// It's an offline event.
}
}
});
}
}, sitesProvider.getCurrentSiteId());
}
/**
@ -114,8 +141,19 @@ export class AddonCalendarListPage implements OnDestroy {
this.daysLoaded = 0;
this.emptyEventsTimes = 0;
const promises = [];
if (this.calendarProvider.canEditEventsInSite()) {
// Site allows creating events. Check if the user has permissions to do so.
promises.push(this.calendarProvider.getAllowedEventTypes().then((types) => {
this.canCreate = Object.keys(types).length > 0;
}).catch(() => {
this.canCreate = false;
}));
}
// Load courses for the popover.
return this.coursesProvider.getUserCourses(false).then((courses) => {
promises.push(this.coursesProvider.getUserCourses(false).then((courses) => {
// Add "All courses".
courses.unshift(this.allCourses);
this.courses = courses;
@ -127,7 +165,9 @@ export class AddonCalendarListPage implements OnDestroy {
}
return this.fetchEvents(refresh);
});
}));
return Promise.all(promises);
}
/**
@ -308,21 +348,23 @@ export class AddonCalendarListPage implements OnDestroy {
/**
* Refresh the events.
*
* @param {any} refresher Refresher.
* @param {any} [refresher] Refresher.
* @return {Promise<any>} Promise resolved when done.
*/
refreshEvents(refresher: any): void {
refreshEvents(refresher?: any): Promise<any> {
const promises = [];
promises.push(this.calendarProvider.invalidateEventsList());
promises.push(this.calendarProvider.invalidateAllowedEventTypes());
if (this.categoriesRetrieved) {
promises.push(this.coursesProvider.invalidateCategories(0, true));
this.categoriesRetrieved = false;
}
Promise.all(promises).finally(() => {
this.fetchData(true).finally(() => {
refresher.complete();
return Promise.all(promises).finally(() => {
return this.fetchData(true).finally(() => {
refresher && refresher.complete();
});
});
}
@ -384,6 +426,18 @@ export class AddonCalendarListPage implements OnDestroy {
});
}
/**
* Open page to create an event.
*/
openCreate(): void {
const params: any = {};
if (this.filter.course.id != this.allCourses.id) {
params.courseId = this.filter.course.id;
}
this.splitviewCtrl.push('AddonCalendarEditEventPage', params);
}
/**
* Open calendar events settings.
*/
@ -406,5 +460,6 @@ export class AddonCalendarListPage implements OnDestroy {
*/
ngOnDestroy(): void {
this.obsDefaultTimeChange && this.obsDefaultTimeChange.off();
this.newEventObserver && this.newEventObserver.off();
}
}

View File

@ -18,6 +18,7 @@ import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites';
import { CoreSite } from '@classes/site';
import { CoreCoursesProvider } from '@core/courses/providers/courses';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreGroupsProvider } from '@providers/groups';
import { CoreConstants } from '@core/constants';
import { CoreLocalNotificationsProvider } from '@providers/local-notifications';
@ -35,6 +36,12 @@ export class AddonCalendarProvider {
static DEFAULT_NOTIFICATION_TIME_CHANGED = 'AddonCalendarDefaultNotificationTimeChangedEvent';
static DEFAULT_NOTIFICATION_TIME_SETTING = 'mmaCalendarDefaultNotifTime';
static DEFAULT_NOTIFICATION_TIME = 60;
static NEW_EVENT_EVENT = 'addon_calendar_new_event';
static TYPE_CATEGORY = 'category';
static TYPE_COURSE = 'course';
static TYPE_GROUP = 'group';
static TYPE_SITE = 'site';
static TYPE_USER = 'user';
protected ROOT_CACHE_KEY = 'mmaCalendar:';
// Variables for database.
@ -206,11 +213,37 @@ export class AddonCalendarProvider {
constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private groupsProvider: CoreGroupsProvider,
private coursesProvider: CoreCoursesProvider, private timeUtils: CoreTimeUtilsProvider,
private localNotificationsProvider: CoreLocalNotificationsProvider, private configProvider: CoreConfigProvider) {
private localNotificationsProvider: CoreLocalNotificationsProvider, private configProvider: CoreConfigProvider,
private utils: CoreUtilsProvider) {
this.logger = logger.getInstance('AddonCalendarProvider');
this.sitesProvider.registerSiteSchema(this.siteSchema);
}
/**
* Check if a certain site allows creating and editing events.
*
* @param {string} [siteId] Site Id. If not defined, use current site.
* @return {Promise<boolean>} Promise resolved with true if can create/edit.
*/
canEditEvents(siteId?: string): Promise<boolean> {
return this.sitesProvider.getSite(siteId).then((site) => {
return this.canEditEventsInSite(site);
});
}
/**
* Check if a certain site allows creating and editing events.
*
* @param {CoreSite} [site] Site. If not defined, use current site.
* @return {boolean} Whether events can be created and edited.
*/
canEditEventsInSite(site?: CoreSite): boolean {
site = site || this.sitesProvider.getCurrentSite();
// The WS to create/edit events requires a fix that was integrated in 3.7.1.
return site.isVersionGreaterEqualThan('3.7.1');
}
/**
* Removes expired events from local DB.
*
@ -255,6 +288,39 @@ export class AddonCalendarProvider {
});
}
/**
* Get access information for a calendar (either course calendar or site calendar).
*
* @param {number} [courseId] Course ID. If not defined, site calendar.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with object with access information.
* @since 3.7
*/
getAccessInformation(courseId?: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
const params: any = {},
preSets = {
cacheKey: this.getAccessInformationCacheKey(courseId)
};
if (courseId) {
params.courseid = courseId;
}
return site.read('core_calendar_get_calendar_access_information', params, preSets);
});
}
/**
* Get cache key for calendar access information WS calls.
*
* @param {number} [courseId] Course ID.
* @return {string} Cache key.
*/
protected getAccessInformationCacheKey(courseId?: number): string {
return this.ROOT_CACHE_KEY + 'accessInformation:' + (courseId || 0);
}
/**
* Get all calendar events from local Db.
*
@ -267,6 +333,50 @@ export class AddonCalendarProvider {
});
}
/**
* Get the type of events a user can create (either course calendar or site calendar).
*
* @param {number} [courseId] Course ID. If not defined, site calendar.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved with an object indicating the types.
* @since 3.7
*/
getAllowedEventTypes(courseId?: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
const params: any = {},
preSets = {
cacheKey: this.getAllowedEventTypesCacheKey(courseId)
};
if (courseId) {
params.courseid = courseId;
}
return site.read('core_calendar_get_allowed_event_types', params, preSets).then((response) => {
// Convert the array to an object.
const result = {};
if (response.allowedeventtypes) {
response.allowedeventtypes.map((type) => {
result[type] = true;
});
}
return result;
});
});
}
/**
* Get cache key for calendar allowed event types WS calls.
*
* @param {number} [courseId] Course ID.
* @return {string} Cache key.
*/
protected getAllowedEventTypesCacheKey(courseId?: number): string {
return this.ROOT_CACHE_KEY + 'allowedEventTypes:' + (courseId || 0);
}
/**
* Get the configured default notification time.
*
@ -504,6 +614,32 @@ export class AddonCalendarProvider {
return this.getEventsListPrefixCacheKey() + daysToStart + ':' + daysInterval;
}
/**
* Invalidates access information.
*
* @param {number} [courseId] Course ID. If not defined, site calendar.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when the data is invalidated.
*/
invalidateAccessInformation(courseId?: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.invalidateWsCacheForKey(this.getAccessInformationCacheKey(courseId));
});
}
/**
* Invalidates allowed event types.
*
* @param {number} [courseId] Course ID. If not defined, site calendar.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when the data is invalidated.
*/
invalidateAllowedEventTypes(courseId?: number, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return site.invalidateWsCacheForKey(this.getAllowedEventTypesCacheKey(courseId));
});
}
/**
* Invalidates events list and all the single events and related info.
*
@ -780,4 +916,35 @@ export class AddonCalendarProvider {
}));
});
}
/**
* Submit an event, either to create it or to edit it.
*
* @param {number} eventId ID of the event. If undefined/null, create a new event.
* @param {any} formData Form data.
* @param {string} [siteId] Site ID. If not provided, current site.
* @return {Promise<any>} Promise resolved when done.
*/
submitEvent(eventId: number, formData: any, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
// Add data that is "hidden" in web.
formData.id = eventId || 0;
formData.userid = site.getUserId();
formData.visible = 1;
formData.instance = 0;
formData['_qf__core_calendar_local_event_forms_create'] = 1;
const params = {
formdata: this.utils.objectToGetParams(formData)
};
return site.write('core_calendar_submit_create_update_form', params).then((result) => {
if (result.validationerror) {
return Promise.reject(null);
}
return result.event;
});
});
}
}

View File

@ -15,6 +15,7 @@
import { Injectable } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreCourseProvider } from '@core/course/providers/course';
import { AddonCalendarProvider } from './calendar';
/**
* Service that provides some features regarding lists of courses and categories.
@ -47,4 +48,73 @@ export class AddonCalendarHelperProvider {
e.moduleIcon = e.icon;
}
}
/**
* Get options (name & value) for each allowed event type.
*
* @param {any} eventTypes Result of getAllowedEventTypes.
* @return {{name: string, value: string}[]} Options.
*/
getEventTypeOptions(eventTypes: any): {name: string, value: string}[] {
const options = [];
if (eventTypes.user) {
options.push({name: 'core.user', value: AddonCalendarProvider.TYPE_USER});
}
if (eventTypes.group) {
options.push({name: 'core.group', value: AddonCalendarProvider.TYPE_GROUP});
}
if (eventTypes.course) {
options.push({name: 'core.course', value: AddonCalendarProvider.TYPE_COURSE});
}
if (eventTypes.category) {
options.push({name: 'core.category', value: AddonCalendarProvider.TYPE_CATEGORY});
}
if (eventTypes.site) {
options.push({name: 'core.site', value: AddonCalendarProvider.TYPE_SITE});
}
return options;
}
/**
* Check if the data of an event has changed.
*
* @param {any} data Current data.
* @param {any} [original] Original data.
* @return {boolean} True if data has changed, false otherwise.
*/
hasEventDataChanged(data: any, original?: any): boolean {
if (!original) {
// There is no original data, assume it hasn't changed.
return false;
}
// Check the fields that don't depend on any other.
if (data.name != original.name || data.timestart != original.timestart || data.eventtype != original.eventtype ||
data.description != original.description || data.location != original.location ||
data.duration != original.duration || data.repeat != original.repeat) {
return true;
}
// Check data that depends on eventtype.
if ((data.eventtype == AddonCalendarProvider.TYPE_CATEGORY && data.categoryid != original.categoryid) ||
(data.eventtype == AddonCalendarProvider.TYPE_COURSE && data.courseid != original.courseid) ||
(data.eventtype == AddonCalendarProvider.TYPE_GROUP && data.groupcourseid != original.groupcourseid &&
data.groupid != original.groupid)) {
return true;
}
// Check data that depends on duration.
if ((data.duration == 1 && data.timedurationuntil != original.timedurationuntil) ||
(data.duration == 2 && data.timedurationminutes != original.timedurationminutes)) {
return true;
}
if (data.repeat && data.repeats != original.repeats) {
return true;
}
return false;
}
}

View File

@ -87,13 +87,26 @@
"addon.calendar.calendarevents": "Calendar events",
"addon.calendar.calendarreminders": "Calendar reminders",
"addon.calendar.defaultnotificationtime": "Default notification time",
"addon.calendar.durationminutes": "Duration in minutes",
"addon.calendar.durationnone": "Without duration",
"addon.calendar.durationuntil": "Until",
"addon.calendar.editevent": "Editing event",
"addon.calendar.errorloadevent": "Error loading event.",
"addon.calendar.errorloadevents": "Error loading events.",
"addon.calendar.eventduration": "Duration",
"addon.calendar.eventendtime": "End time",
"addon.calendar.eventname": "Event title",
"addon.calendar.eventstarttime": "Start time",
"addon.calendar.eventtype": "Event type",
"addon.calendar.gotoactivity": "Go to activity",
"addon.calendar.invalidtimedurationminutes": "The duration in minutes you have entered is invalid. Please enter the duration in minutes greater than 0 or select no duration.",
"addon.calendar.invalidtimedurationuntil": "The date and time you selected for duration until is before the start time of the event. Please correct this before proceeding.",
"addon.calendar.newevent": "New event",
"addon.calendar.noevents": "There are no events",
"addon.calendar.nopermissiontoupdatecalendar": "Sorry, but you do not have permission to update the calendar event",
"addon.calendar.reminders": "Reminders",
"addon.calendar.repeatevent": "Repeat this event",
"addon.calendar.repeatweeksl": "Repeat weekly, creating altogether",
"addon.calendar.setnewreminder": "Set a new reminder",
"addon.calendar.typecategory": "Category event",
"addon.calendar.typeclose": "Close event",
@ -1328,6 +1341,7 @@
"core.course.warningmanualcompletionmodified": "The manual completion of an activity was modified on the site.",
"core.course.warningofflinemanualcompletiondeleted": "Some offline manual completion of course '{{name}}' has been deleted. {{error}}",
"core.coursedetails": "Course details",
"core.coursenogroups": "This course doesn't have any group.",
"core.courses.addtofavourites": "Star this course",
"core.courses.allowguests": "This course allows guest users to enter",
"core.courses.availablecourses": "Available courses",
@ -1457,6 +1471,7 @@
"core.grades.range": "Range",
"core.grades.rank": "Rank",
"core.grades.weight": "Weight",
"core.group": "Group",
"core.groupsseparate": "Separate groups",
"core.groupsvisible": "Visible groups",
"core.hasdatatosync": "This {{$a}} has offline data to be synchronised.",
@ -1624,6 +1639,7 @@
"core.nopermissionerror": "Sorry, but you do not currently have permissions to do that",
"core.nopermissions": "Sorry, but you do not currently have permissions to do that ({{$a}}).",
"core.noresults": "No results",
"core.noselection": "No selection",
"core.notapplicable": "n/a",
"core.notenrolledprofile": "This profile is not available because this user is not enrolled in this course.",
"core.notice": "Notice",
@ -1692,6 +1708,9 @@
"core.sec": "sec",
"core.secs": "secs",
"core.seemoredetail": "Click here to see more detail",
"core.selectacategory": "Please select a category",
"core.selectacourse": "Select a course",
"core.selectagroup": "Select a group",
"core.send": "Send",
"core.sending": "Sending",
"core.serverconnection": "Error connecting to the server",
@ -1760,6 +1779,7 @@
"core.sharedfiles.sharedfiles": "Shared files",
"core.sharedfiles.successstorefile": "File successfully stored. Select the file to upload to your private files or use in an activity.",
"core.show": "Show",
"core.showless": "Show less...",
"core.showmore": "Show more...",
"core.site": "Site",
"core.sitehome.sitehome": "Site home",
@ -1808,6 +1828,7 @@
"core.unlimited": "Unlimited",
"core.unzipping": "Unzipping",
"core.upgraderunning": "Site is being upgraded, please retry later.",
"core.user": "User",
"core.user.address": "Address",
"core.user.city": "City/town",
"core.user.contact": "Contact",

View File

@ -51,6 +51,7 @@
"copiedtoclipboard": "Text copied to clipboard",
"course": "Course",
"coursedetails": "Course details",
"coursenogroups": "This course doesn't have any group.",
"currentdevice": "Current device",
"datastoredoffline": "Data stored in the device because it couldn't be sent. It will be sent automatically later.",
"date": "Date",
@ -104,6 +105,7 @@
"forcepasswordchangenotice": "You must change your password to proceed.",
"fulllistofcourses": "All courses",
"fullnameandsitename": "{{fullname}} ({{sitename}})",
"group": "Group",
"groupsseparate": "Separate groups",
"groupsvisible": "Visible groups",
"hasdatatosync": "This {{$a}} has offline data to be synchronised.",
@ -174,6 +176,7 @@
"nopermissionerror": "Sorry, but you do not currently have permissions to do that",
"nopermissions": "Sorry, but you do not currently have permissions to do that ({{$a}}).",
"noresults": "No results",
"noselection": "No selection",
"notapplicable": "n/a",
"notenrolledprofile": "This profile is not available because this user is not enrolled in this course.",
"notice": "Notice",
@ -214,10 +217,14 @@
"sec": "sec",
"secs": "secs",
"seemoredetail": "Click here to see more detail",
"selectacategory": "Please select a category",
"selectacourse": "Select a course",
"selectagroup": "Select a group",
"send": "Send",
"sending": "Sending",
"serverconnection": "Error connecting to the server",
"show": "Show",
"showless": "Show less...",
"showmore": "Show more...",
"site": "Site",
"sitemaintenance": "The site is undergoing maintenance and is currently not available",
@ -264,6 +271,7 @@
"unlimited": "Unlimited",
"unzipping": "Unzipping",
"upgraderunning": "Site is being upgraded, please retry later.",
"user": "User",
"userdeleted": "This user account has been deleted",
"userdetails": "User details",
"usernotfullysetup": "User not fully set-up",

View File

@ -376,13 +376,15 @@ export class CoreUtilsProvider {
}
/**
* Flatten an object, moving subobjects' properties to the first level using dot notation. E.g.:
* {a: {b: 1, c: 2}, d: 3} -> {'a.b': 1, 'a.c': 2, d: 3}
* Flatten an object, moving subobjects' properties to the first level.
* It supports 2 notations: dot notation and square brackets.
* E.g.: {a: {b: 1, c: 2}, d: 3} -> {'a.b': 1, 'a.c': 2, d: 3}
*
* @param {object} obj Object to flatten.
* @return {object} Flatten object.
* @param {boolean} [useDotNotation] Whether to use dot notation '.' or square brackets '['.
* @return {object} Flattened object.
*/
flattenObject(obj: object): object {
flattenObject(obj: object, useDotNotation?: boolean): object {
const toReturn = {};
for (const name in obj) {
@ -398,7 +400,8 @@ export class CoreUtilsProvider {
continue;
}
toReturn[name + '.' + subName] = flatObject[subName];
const newName = useDotNotation ? name + '.' + subName : name + '[' + subName + ']';
toReturn[newName] = flatObject[subName];
}
} else {
toReturn[name] = value;
@ -1051,6 +1054,37 @@ export class CoreUtilsProvider {
return mapped;
}
/**
* Convert an object to a format of GET param. E.g.: {a: 1, b: 2} -> a=1&b=2
*
* @param {any} object Object to convert.
* @param {boolean} [removeEmpty=true] Whether to remove params whose value is empty/null/undefined.
* @return {string} GET params.
*/
objectToGetParams(object: any, removeEmpty: boolean = true): string {
// First of all, flatten the object so all properties are in the first level.
const flattened = this.flattenObject(object);
let result = '',
joinChar = '';
for (const name in flattened) {
let value = flattened[name];
if (removeEmpty && (value === null || typeof value == 'undefined' || value === '')) {
continue;
}
if (typeof value == 'boolean') {
value = value ? 1 : 0;
}
result += joinChar + name + '=' + value;
joinChar = '&';
}
return result;
}
/**
* Add a prefix to all the keys in an object.
*