diff --git a/scripts/langindex.json b/scripts/langindex.json index 9056e7451..b41c53462 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -84,16 +84,30 @@ "addon.blog.showonlyyourentries": "local_moodlemobileapp", "addon.blog.siteblogheading": "blog", "addon.calendar.calendar": "calendar", + "addon.calendar.calendarevent": "local_moodlemobileapp", "addon.calendar.calendarevents": "local_moodlemobileapp", "addon.calendar.calendarreminders": "local_moodlemobileapp", "addon.calendar.defaultnotificationtime": "local_moodlemobileapp", + "addon.calendar.durationminutes": "calendar", + "addon.calendar.durationnone": "calendar", + "addon.calendar.durationuntil": "calendar", + "addon.calendar.editevent": "calendar", "addon.calendar.errorloadevent": "local_moodlemobileapp", "addon.calendar.errorloadevents": "local_moodlemobileapp", + "addon.calendar.eventduration": "calendar", "addon.calendar.eventendtime": "calendar", + "addon.calendar.eventname": "calendar", "addon.calendar.eventstarttime": "calendar", + "addon.calendar.eventtype": "calendar", "addon.calendar.gotoactivity": "calendar", + "addon.calendar.invalidtimedurationminutes": "calendar", + "addon.calendar.invalidtimedurationuntil": "calendar", + "addon.calendar.newevent": "calendar", "addon.calendar.noevents": "local_moodlemobileapp", + "addon.calendar.nopermissiontoupdatecalendar": "error", "addon.calendar.reminders": "local_moodlemobileapp", + "addon.calendar.repeatevent": "calendar", + "addon.calendar.repeatweeksl": "calendar", "addon.calendar.setnewreminder": "local_moodlemobileapp", "addon.calendar.typecategory": "calendar", "addon.calendar.typeclose": "calendar", @@ -1333,6 +1347,7 @@ "core.course.warningmanualcompletionmodified": "local_moodlemobileapp", "core.course.warningofflinemanualcompletiondeleted": "local_moodlemobileapp", "core.coursedetails": "moodle", + "core.coursenogroups": "local_moodlemobileapp", "core.courses.addtofavourites": "block_myoverview", "core.courses.allowguests": "enrol_guest", "core.courses.availablecourses": "moodle", @@ -1463,6 +1478,7 @@ "core.grades.range": "grades", "core.grades.rank": "grades", "core.grades.weight": "grades", + "core.group": "moodle", "core.groupsseparate": "moodle", "core.groupsvisible": "moodle", "core.hasdatatosync": "local_moodlemobileapp", @@ -1698,6 +1714,9 @@ "core.sec": "moodle", "core.secs": "moodle", "core.seemoredetail": "survey", + "core.selectacategory": "moodle", + "core.selectacourse": "moodle", + "core.selectagroup": "moodle", "core.send": "message", "core.sending": "chat", "core.serverconnection": "error", @@ -1766,6 +1785,7 @@ "core.sharedfiles.sharedfiles": "local_moodlemobileapp", "core.sharedfiles.successstorefile": "local_moodlemobileapp", "core.show": "moodle", + "core.showless": "form", "core.showmore": "form", "core.site": "moodle", "core.sitehome.sitehome": "moodle", @@ -1814,6 +1834,7 @@ "core.unlimited": "moodle", "core.unzipping": "local_moodlemobileapp", "core.upgraderunning": "error", + "core.user": "moodle", "core.user.address": "moodle", "core.user.city": "moodle", "core.user.contact": "local_moodlemobileapp", diff --git a/src/addon/calendar/calendar.module.ts b/src/addon/calendar/calendar.module.ts index 3146e6e69..c8f9cef82 100644 --- a/src/addon/calendar/calendar.module.ts +++ b/src/addon/calendar/calendar.module.ts @@ -14,9 +14,13 @@ import { NgModule } from '@angular/core'; import { AddonCalendarProvider } from './providers/calendar'; +import { AddonCalendarOfflineProvider } from './providers/calendar-offline'; import { AddonCalendarHelperProvider } from './providers/helper'; +import { AddonCalendarSyncProvider } from './providers/calendar-sync'; import { AddonCalendarMainMenuHandler } from './providers/mainmenu-handler'; +import { AddonCalendarSyncCronHandler } from './providers/sync-cron-handler'; import { CoreMainMenuDelegate } from '@core/mainmenu/providers/delegate'; +import { CoreCronDelegate } from '@providers/cron'; import { CoreInitDelegate } from '@providers/init'; import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; import { CoreLoginHelperProvider } from '@core/login/providers/helper'; @@ -25,7 +29,9 @@ import { CoreUpdateManagerProvider } from '@providers/update-manager'; // List of providers (without handlers). export const ADDON_CALENDAR_PROVIDERS: any[] = [ AddonCalendarProvider, - AddonCalendarHelperProvider + AddonCalendarOfflineProvider, + AddonCalendarHelperProvider, + AddonCalendarSyncProvider ]; @NgModule({ @@ -35,15 +41,21 @@ export const ADDON_CALENDAR_PROVIDERS: any[] = [ ], providers: [ AddonCalendarProvider, + AddonCalendarOfflineProvider, AddonCalendarHelperProvider, - AddonCalendarMainMenuHandler + AddonCalendarSyncProvider, + AddonCalendarMainMenuHandler, + AddonCalendarSyncCronHandler ] }) export class AddonCalendarModule { constructor(mainMenuDelegate: CoreMainMenuDelegate, calendarHandler: AddonCalendarMainMenuHandler, initDelegate: CoreInitDelegate, calendarProvider: AddonCalendarProvider, loginHelper: CoreLoginHelperProvider, - localNotificationsProvider: CoreLocalNotificationsProvider, updateManager: CoreUpdateManagerProvider) { + localNotificationsProvider: CoreLocalNotificationsProvider, updateManager: CoreUpdateManagerProvider, + cronDelegate: CoreCronDelegate, syncHandler: AddonCalendarSyncCronHandler) { + mainMenuDelegate.registerHandler(calendarHandler); + cronDelegate.register(syncHandler); initDelegate.ready().then(() => { calendarProvider.scheduleAllSitesEventsNotifications(); diff --git a/src/addon/calendar/lang/en.json b/src/addon/calendar/lang/en.json index 6ccb04caa..a7a531836 100644 --- a/src/addon/calendar/lang/en.json +++ b/src/addon/calendar/lang/en.json @@ -1,15 +1,29 @@ { "calendar": "Calendar", + "calendarevent": "Calendar event", "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", diff --git a/src/addon/calendar/pages/edit-event/edit-event.html b/src/addon/calendar/pages/edit-event/edit-event.html new file mode 100644 index 000000000..0cf401536 --- /dev/null +++ b/src/addon/calendar/pages/edit-event/edit-event.html @@ -0,0 +1,144 @@ + + + + + + + + + + + +
+ + +

{{ 'addon.calendar.eventname' | translate }}

+ + +
+ + + +

{{ 'core.date' | translate }}

+ + +
+ + + +

{{ 'addon.calendar.eventtype' | translate }}

+ + {{ type.name | translate }} + +
+ + + +

{{ 'core.category' | translate }}

+ + {{ category.name }} + +
+ + + +

{{ 'core.course' | translate }}

+ + + +
+ + + + + +

{{ 'core.course' | translate }}

+ + + +
+ + +

{{ 'core.coursenogroups' | translate }}

+
+ + +

{{ 'core.group' | translate }}

+ + {{ group.name }} + +
+ + + + +
+ + + + + {{ 'core.showmore' | translate }} + + {{ 'core.showless' | translate }} + + + + + +

{{ 'core.description' | translate }}

+ +
+ + + +

{{ 'core.location' | translate }}

+ +
+ + +
+

{{ 'addon.calendar.eventduration' | translate }}

+ + {{ 'addon.calendar.durationnone' | translate }} + + + + {{ 'addon.calendar.durationuntil' | translate }} + + + + + + + {{ 'addon.calendar.durationminutes' | translate }} + + + + + +
+ + + +

{{ 'addon.calendar.repeatevent' | translate }}

+ +
+ +

{{ 'addon.calendar.repeatweeksl' | translate }}

+ +
+
+ + + + + + + + + + + +
+
+
diff --git a/src/addon/calendar/pages/edit-event/edit-event.module.ts b/src/addon/calendar/pages/edit-event/edit-event.module.ts new file mode 100644 index 000000000..c5b20e969 --- /dev/null +++ b/src/addon/calendar/pages/edit-event/edit-event.module.ts @@ -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 {} diff --git a/src/addon/calendar/pages/edit-event/edit-event.scss b/src/addon/calendar/pages/edit-event/edit-event.scss new file mode 100644 index 000000000..3c43c635e --- /dev/null +++ b/src/addon/calendar/pages/edit-event/edit-event.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/src/addon/calendar/pages/edit-event/edit-event.ts b/src/addon/calendar/pages/edit-event/edit-event.ts new file mode 100644 index 000000000..bf394c205 --- /dev/null +++ b/src/addon/calendar/pages/edit-event/edit-event.ts @@ -0,0 +1,466 @@ +// (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, OnDestroy, 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 { CoreSyncProvider } from '@providers/sync'; +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 { AddonCalendarOfflineProvider } from '../../providers/calendar-offline'; +import { AddonCalendarHelperProvider } from '../../providers/helper'; +import { AddonCalendarSyncProvider } from '../../providers/calendar-sync'; +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, OnDestroy { + + @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; + protected isDestroyed = false; + + 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 calendarOffline: AddonCalendarOfflineProvider, + private calendarHelper: AddonCalendarHelperProvider, + private calendarSync: AddonCalendarSyncProvider, + private fb: FormBuilder, + private syncProvider: CoreSyncProvider, + @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(''); + + const currentDate = this.timeUtils.toDatetimeFormat(); + + this.eventForm.addControl('name', this.fb.control('', Validators.required)); + this.eventForm.addControl('timestart', this.fb.control(currentDate, 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(currentDate)); + 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. + * + * @param {boolean} [refresh] Whether it's refreshing data. + * @return {Promise} Promise resolved when done. + */ + protected fetchData(refresh?: boolean): Promise { + let accessInfo; + + // Get access info. + return this.calendarProvider.getAccessInformation(this.courseId).then((info) => { + accessInfo = info; + + return this.calendarProvider.getAllowedEventTypes(this.courseId); + }).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 (this.eventId && !refresh) { + // If editing an event, get offline data. Wait for sync first. + + promises.push(this.calendarSync.waitForSync(AddonCalendarSyncProvider.SYNC_ID).then(() => { + // Do not block if the scope is already destroyed. + if (!this.isDestroyed) { + this.syncProvider.blockOperation(AddonCalendarProvider.COMPONENT, this.eventId); + } + + // Get the event data if there's any. + return this.calendarOffline.getEvent(this.eventId).then((event) => { + this.hasOffline = true; + + // Load the data in the form. + this.eventForm.controls.name.setValue(event.name); + this.eventForm.controls.timestart.setValue(this.timeUtils.toDatetimeFormat(event.timestart * 1000)); + this.eventForm.controls.eventtype.setValue(event.eventtype); + this.eventForm.controls.categoryid.setValue(event.categoryid || ''); + this.eventForm.controls.courseid.setValue(event.courseid || ''); + this.eventForm.controls.groupcourseid.setValue(event.groupcourseid || ''); + this.eventForm.controls.groupid.setValue(event.groupid || ''); + this.eventForm.controls.description.setValue(event.description); + this.eventForm.controls.location.setValue(event.location); + this.eventForm.controls.duration.setValue(event.duration); + this.eventForm.controls.timedurationuntil.setValue( + this.timeUtils.toDatetimeFormat((event.timedurationuntil * 1000) || Date.now())); + this.eventForm.controls.timedurationminutes.setValue(event.timedurationminutes || ''); + this.eventForm.controls.repeat.setValue(!!event.repeat); + this.eventForm.controls.repeats.setValue(event.repeats || '1'); + }).catch(() => { + // No offline data. + this.hasOffline = false; + }); + })); + } + + 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(() => { + if (!this.eventTypeControl.value) { + // Initialize event type value. 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(true).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 = this.timeUtils.datetimeToDate(formData.timestart), + timeUntilDate = this.timeUtils.datetimeToDate(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((result) => { + this.returnToList(result.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 { + // Unblock the sync because the view will be destroyed and the sync process could be triggered before ngOnDestroy. + this.unblockSync(); + + if (event) { + const data: any = { + event: event + }; + this.eventsProvider.trigger(AddonCalendarProvider.NEW_EVENT_EVENT, data, this.currentSite.getId()); + } else { + this.eventsProvider.trigger(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, {}, 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(() => { + this.calendarOffline.deleteEvent(this.eventId).then(() => { + this.returnToList(); + }).catch(() => { + // Shouldn't happen. + this.domUtils.showErrorModal('Error discarding event.'); + }); + }).catch(() => { + // Cancelled. + }); + } + + /** + * Check if we can leave the page or not. + * + * @return {boolean|Promise} Resolved if we can leave it, rejected if not. + */ + ionViewCanLeave(): boolean | Promise { + + 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(); + } + } + + protected unblockSync(): void { + if (this.eventId) { + this.syncProvider.unblockOperation(AddonCalendarProvider.COMPONENT, this.eventId); + } + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.unblockSync(); + this.isDestroyed = true; + } +} diff --git a/src/addon/calendar/pages/event/event.html b/src/addon/calendar/pages/event/event.html index 3c40c931a..d823697c6 100644 --- a/src/addon/calendar/pages/event/event.html +++ b/src/addon/calendar/pages/event/event.html @@ -26,6 +26,10 @@

{{ 'core.course' | translate}}

+ +

{{ 'core.group' | translate}}

+

{{ groupName }}

+

{{ 'core.category' | translate}}

diff --git a/src/addon/calendar/pages/event/event.ts b/src/addon/calendar/pages/event/event.ts index ece9ca7bf..77d4bce09 100644 --- a/src/addon/calendar/pages/event/event.ts +++ b/src/addon/calendar/pages/event/event.ts @@ -24,6 +24,7 @@ import { CoreSitesProvider } from '@providers/sites'; import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreGroupsProvider } from '@providers/groups'; /** * Page that displays a single calendar event. @@ -46,6 +47,7 @@ export class AddonCalendarEventPage { event: any = {}; title: string; courseName: string; + groupName: string; courseUrl = ''; notificationsEnabled = false; moduleUrl = ''; @@ -58,7 +60,8 @@ export class AddonCalendarEventPage { private domUtils: CoreDomUtilsProvider, private coursesProvider: CoreCoursesProvider, private calendarHelper: AddonCalendarHelperProvider, private sitesProvider: CoreSitesProvider, localNotificationsProvider: CoreLocalNotificationsProvider, private courseProvider: CoreCourseProvider, - private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider) { + private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider, + private groupsProvider: CoreGroupsProvider) { this.eventId = navParams.get('id'); this.notificationsEnabled = localNotificationsProvider.isAvailable(); @@ -165,6 +168,21 @@ export class AddonCalendarEventPage { })); } + // If it's a group event, get the name of the group. + const courseId = canGetById && event.course ? event.course.id : event.courseid; + if (courseId && event.groupid) { + promises.push(this.groupsProvider.getUserGroupsInCourse(event.courseid).then((groups) => { + const group = groups.find((group) => { + return group.id == event.groupid; + }); + + this.groupName = group ? group.name : ''; + }).catch(() => { + // Error getting groups, just don't show the group name. + this.groupName = ''; + })); + } + if (canGetById && event.iscategoryevent) { this.categoryPath = event.category.nestedname; } diff --git a/src/addon/calendar/pages/list/list.html b/src/addon/calendar/pages/list/list.html index e5cdc3eb0..0204b9acf 100644 --- a/src/addon/calendar/pages/list/list.html +++ b/src/addon/calendar/pages/list/list.html @@ -7,19 +7,39 @@ + - + + + + {{ 'core.hasdatatosync' | translate:{$a: 'addon.calendar.calendar' | translate} }} + + + + + {{ 'core.notsent' | translate }} +
+ +

+

+ {{ event.timestart * 1000 | coreFormatDate: "strftimedatetimeshort" }} + - {{ (event.timestart + event.timeduration) * 1000 | coreFormatDate: "strftimedatetimeshort" }} +

+
+ + + @@ -40,5 +60,12 @@ + + + + + \ No newline at end of file diff --git a/src/addon/calendar/pages/list/list.ts b/src/addon/calendar/pages/list/list.ts index 7722dc1c4..1a57c4c0b 100644 --- a/src/addon/calendar/pages/list/list.ts +++ b/src/addon/calendar/pages/list/list.ts @@ -12,11 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, ViewChild, OnDestroy } from '@angular/core'; +import { Component, ViewChild, OnDestroy, NgZone } from '@angular/core'; import { IonicPage, Content, PopoverController, NavParams, NavController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { AddonCalendarProvider } from '../../providers/calendar'; +import { AddonCalendarOfflineProvider } from '../../providers/calendar-offline'; import { AddonCalendarHelperProvider } from '../../providers/helper'; +import { AddonCalendarSyncProvider } from '../../providers/calendar-sync'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUtilsProvider } from '@providers/utils/utils'; @@ -27,6 +29,7 @@ import { CoreEventsProvider } from '@providers/events'; import { CoreAppProvider } from '@providers/app'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import * as moment from 'moment'; +import { Network } from '@ionic-native/network'; /** * Page that displays the list of calendar events. @@ -54,10 +57,15 @@ export class AddonCalendarListPage implements OnDestroy { protected obsDefaultTimeChange: any; protected eventId: number; protected preSelectedCourseId: number; + protected newEventObserver: any; + protected discardedObserver: any; + protected syncObserver: any; + protected onlineObserver: any; courses: any[]; eventsLoaded = false; events = []; + offlineEvents = []; notificationsEnabled = false; filteredEvents = []; canLoadMore = false; @@ -65,15 +73,22 @@ export class AddonCalendarListPage implements OnDestroy { filter = { course: this.allCourses }; + canCreate = false; + hasOffline = false; + isOnline = false; + syncIcon: string; // Sync icon. constructor(private translate: TranslateService, private calendarProvider: AddonCalendarProvider, navParams: NavParams, private domUtils: CoreDomUtilsProvider, private coursesProvider: CoreCoursesProvider, private utils: CoreUtilsProvider, - private calendarHelper: AddonCalendarHelperProvider, sitesProvider: CoreSitesProvider, + private calendarHelper: AddonCalendarHelperProvider, private sitesProvider: CoreSitesProvider, zone: NgZone, localNotificationsProvider: CoreLocalNotificationsProvider, private popoverCtrl: PopoverController, - eventsProvider: CoreEventsProvider, private navCtrl: NavController, appProvider: CoreAppProvider) { + private eventsProvider: CoreEventsProvider, private navCtrl: NavController, private appProvider: CoreAppProvider, + private calendarOffline: AddonCalendarOfflineProvider, private calendarSync: AddonCalendarSyncProvider, + network: Network) { 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 +98,50 @@ 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, 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(true, false).finally(() => { + + // In tablet mode try to open the event (only if it's an online event). + if (this.splitviewCtrl.isOn() && data.event.id > 0) { + this.gotoEvent(data.event.id); + } + }); + } + }, sitesProvider.getCurrentSiteId()); + + // Listen for new event discarded event. When it does, reload the data. + this.discardedObserver = eventsProvider.on(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, () => { + if (this.splitviewCtrl.isOn()) { + // Discussion added, clear details page. + this.splitviewCtrl.emptyDetails(); + } + + this.eventsLoaded = false; + this.refreshEvents(true, false); + }, sitesProvider.getCurrentSiteId()); + + // Refresh data if calendar events are synchronized automatically. + this.syncObserver = eventsProvider.on(AddonCalendarSyncProvider.AUTO_SYNCED, (data) => { + this.eventsLoaded = false; + this.refreshEvents(); + }, sitesProvider.getCurrentSiteId()); + + // Refresh online status when changes. + this.onlineObserver = network.onchange().subscribe((online) => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + zone.run(() => { + this.isOnline = online; + }); + }); } /** @@ -94,13 +153,13 @@ export class AddonCalendarListPage implements OnDestroy { this.gotoEvent(this.eventId); } - this.fetchData().then(() => { + this.syncIcon = 'spinner'; + + this.fetchData(false, true, false).then(() => { if (!this.eventId && this.splitviewCtrl.isOn() && this.events.length > 0) { // Take first and load it. this.gotoEvent(this.events[0].id); } - }).finally(() => { - this.eventsLoaded = true; }); } @@ -108,25 +167,76 @@ export class AddonCalendarListPage implements OnDestroy { * Fetch all the data required for the view. * * @param {boolean} [refresh] Empty events array first. + * @param {boolean} [sync] Whether it should try to synchronize offline events. + * @param {boolean} [showErrors] Whether to show sync errors to the user. * @return {Promise} Promise resolved when done. */ - fetchData(refresh: boolean = false): Promise { + fetchData(refresh?: boolean, sync?: boolean, showErrors?: boolean): Promise { this.daysLoaded = 0; this.emptyEventsTimes = 0; + this.isOnline = this.appProvider.isOnline(); - // Load courses for the popover. - return this.coursesProvider.getUserCourses(false).then((courses) => { - // Add "All courses". - courses.unshift(this.allCourses); - this.courses = courses; + let promise; - if (this.preSelectedCourseId) { - this.filter.course = courses.find((course) => { - return course.id == this.preSelectedCourseId; - }); - } + if (sync) { + // Try to synchronize offline events. + promise = this.calendarSync.syncEvents().then((result) => { + if (result.warnings && result.warnings.length) { + this.domUtils.showErrorModal(result.warnings[0]); + } - return this.fetchEvents(refresh); + if (result.updated) { + // Trigger a manual sync event. + this.eventsProvider.trigger(AddonCalendarSyncProvider.MANUAL_SYNCED, { + source: 'list' + }, this.sitesProvider.getCurrentSiteId()); + } + }).catch((error) => { + if (showErrors) { + this.domUtils.showErrorModalDefault(error, 'core.errorsync', true); + } + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + + const promises = []; + const courseId = this.filter.course.id != this.allCourses.id ? this.filter.course.id : undefined; + + promises.push(this.calendarHelper.canEditEvents(courseId).then((canEdit) => { + this.canCreate = canEdit; + })); + + // Load courses for the popover. + promises.push(this.coursesProvider.getUserCourses(false).then((courses) => { + // Add "All courses". + courses.unshift(this.allCourses); + this.courses = courses; + + if (this.preSelectedCourseId) { + this.filter.course = courses.find((course) => { + return course.id == this.preSelectedCourseId; + }); + } + + return this.fetchEvents(refresh); + })); + + // Get offline events. + promises.push(this.calendarOffline.getAllEvents().then((events) => { + this.hasOffline = !!events.length; + + // Format data and sort by timestart. + events.forEach(this.calendarHelper.formatEventData.bind(this.calendarHelper)); + this.offlineEvents = events.sort((a, b) => a.timestart - b.timestart); + })); + + return Promise.all(promises); + }).finally(() => { + this.eventsLoaded = true; + this.syncIcon = 'sync'; }); } @@ -136,7 +246,7 @@ export class AddonCalendarListPage implements OnDestroy { * @param {boolean} [refresh] Empty events array first. * @return {Promise} Promise resolved when done. */ - fetchEvents(refresh: boolean = false): Promise { + fetchEvents(refresh?: boolean): Promise { this.loadMoreError = false; return this.calendarProvider.getEventsList(this.daysLoaded, AddonCalendarProvider.DAYS_INTERVAL).then((events) => { @@ -154,6 +264,8 @@ export class AddonCalendarListPage implements OnDestroy { return this.fetchEvents(); } } else { + events.forEach(this.calendarHelper.formatEventData.bind(this.calendarHelper)); + // Sort the events by timestart, they're ordered by id. events.sort((a, b) => { if (a.timestart == b.timestart) { @@ -163,7 +275,6 @@ export class AddonCalendarListPage implements OnDestroy { return a.timestart - b.timestart; }); - events.forEach(this.calendarHelper.formatEventData.bind(this.calendarHelper)); this.getCategories = this.shouldLoadCategories(events); if (refresh) { @@ -305,25 +416,47 @@ export class AddonCalendarListPage implements OnDestroy { }); } + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @param {Function} [done] Function to call when done. + * @param {boolean} [showErrors] Whether to show sync errors to the user. + * @return {Promise} Promise resolved when done. + */ + doRefresh(refresher?: any, done?: () => void, showErrors?: boolean): Promise { + if (this.eventsLoaded) { + return this.refreshEvents(true, showErrors).finally(() => { + refresher && refresher.complete(); + done && done(); + }); + } + + return Promise.resolve(); + } + /** * Refresh the events. * - * @param {any} refresher Refresher. + * @param {boolean} [sync] Whether it should try to synchronize offline events. + * @param {boolean} [showErrors] Whether to show sync errors to the user. + * @return {Promise} Promise resolved when done. */ - refreshEvents(refresher: any): void { + refreshEvents(sync?: boolean, showErrors?: boolean): Promise { + this.syncIcon = 'spinner'; + 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, sync, showErrors); }); } @@ -377,6 +510,13 @@ export class AddonCalendarListPage implements OnDestroy { this.domUtils.scrollToTop(this.content); this.filteredEvents = this.getFilteredEvents(); + + // Course viewed has changed, check if the user can create events for this course calendar. + const courseId = this.filter.course.id != this.allCourses.id ? this.filter.course.id : undefined; + + this.calendarHelper.canEditEvents(courseId).then((canEdit) => { + this.canCreate = canEdit; + }); } }); popover.present({ @@ -384,6 +524,24 @@ export class AddonCalendarListPage implements OnDestroy { }); } + /** + * Open page to create/edit an event. + * + * @param {number} [eventId] Event ID to edit. + */ + openEdit(eventId?: number): void { + const params: any = {}; + + if (eventId) { + params.eventId = eventId; + } + if (this.filter.course.id != this.allCourses.id) { + params.courseId = this.filter.course.id; + } + + this.splitviewCtrl.push('AddonCalendarEditEventPage', params); + } + /** * Open calendar events settings. */ @@ -398,7 +556,15 @@ export class AddonCalendarListPage implements OnDestroy { */ gotoEvent(eventId: number): void { this.eventId = eventId; - this.splitviewCtrl.push('AddonCalendarEventPage', { id: eventId }); + + if (eventId < 0) { + // It's an offline event, go to the edit page. + this.openEdit(eventId); + } else { + this.splitviewCtrl.push('AddonCalendarEventPage', { + id: eventId + }); + } } /** @@ -406,5 +572,9 @@ export class AddonCalendarListPage implements OnDestroy { */ ngOnDestroy(): void { this.obsDefaultTimeChange && this.obsDefaultTimeChange.off(); + this.newEventObserver && this.newEventObserver.off(); + this.discardedObserver && this.discardedObserver.off(); + this.syncObserver && this.syncObserver.off(); + this.onlineObserver && this.onlineObserver.off(); } } diff --git a/src/addon/calendar/providers/calendar-offline.ts b/src/addon/calendar/providers/calendar-offline.ts new file mode 100644 index 000000000..833ca7d4b --- /dev/null +++ b/src/addon/calendar/providers/calendar-offline.ts @@ -0,0 +1,214 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; + +/** + * Service to handle offline calendar events. + */ +@Injectable() +export class AddonCalendarOfflineProvider { + + // Variables for database. + static EVENTS_TABLE = 'addon_calendar_offline_events'; + + protected siteSchema: CoreSiteSchema = { + name: 'AddonCalendarOfflineProvider', + version: 1, + tables: [ + { + name: AddonCalendarOfflineProvider.EVENTS_TABLE, + columns: [ + { + name: 'id', // Negative for offline entries. + type: 'INTEGER', + }, + { + name: 'name', + type: 'TEXT', + notNull: true + }, + { + name: 'timestart', + type: 'INTEGER', + notNull: true + }, + { + name: 'eventtype', + type: 'TEXT', + notNull: true + }, + { + name: 'categoryid', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'groupcourseid', + type: 'INTEGER', + }, + { + name: 'groupid', + type: 'INTEGER', + }, + { + name: 'description', + type: 'TEXT', + }, + { + name: 'location', + type: 'TEXT', + }, + { + name: 'duration', + type: 'INTEGER', + }, + { + name: 'timedurationuntil', + type: 'INTEGER', + }, + { + name: 'timedurationminutes', + type: 'INTEGER', + }, + { + name: 'repeat', + type: 'INTEGER', + }, + { + name: 'repeats', + type: 'TEXT', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'timecreated', + type: 'INTEGER', + } + ], + primaryKeys: ['id'] + } + ] + }; + + constructor(private sitesProvider: CoreSitesProvider) { + this.sitesProvider.registerSiteSchema(this.siteSchema); + } + + /** + * Delete an offline event. + * + * @param {number} eventId Event ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + deleteEvent(eventId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions: any = { + id: eventId + }; + + return site.getDb().deleteRecords(AddonCalendarOfflineProvider.EVENTS_TABLE, conditions); + }); + } + + /** + * Get all offline events. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with events. + */ + getAllEvents(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(AddonCalendarOfflineProvider.EVENTS_TABLE); + }); + } + + /** + * Get an offline event. + * + * @param {number} eventId Event ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the event. + */ + getEvent(eventId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions: any = { + id: eventId + }; + + return site.getDb().getRecord(AddonCalendarOfflineProvider.EVENTS_TABLE, conditions); + }); + } + + /** + * Check if there are offline events to send. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: true if has offline events, false otherwise. + */ + hasEvents(siteId?: string): Promise { + return this.getAllEvents(siteId).then((events) => { + return !!events.length; + }).catch(() => { + // No offline data found, return false. + return false; + }); + } + + /** + * Offline version for adding a new discussion to a forum. + * + * @param {number} eventId Event ID. If it's a new event, set it to undefined/null. + * @param {any} data Event data. + * @param {number} [timeCreated] The time the event was created. If not defined, current time. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the stored event. + */ + saveEvent(eventId: number, data: any, timeCreated?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + timeCreated = timeCreated || Date.now(); + + const event = { + id: eventId || -timeCreated, + name: data.name, + timestart: data.timestart, + eventtype: data.eventtype, + categoryid: data.categoryid || null, + courseid: data.courseid || null, + groupcourseid: data.groupcourseid || null, + groupid: data.groupid || null, + description: data.description && data.description.text, + location: data.location, + duration: data.duration, + timedurationuntil: data.timedurationuntil, + timedurationminutes: data.timedurationminutes, + repeat: data.repeat ? 1 : 0, + repeats: data.repeats, + timecreated: timeCreated, + userid: site.getUserId() + }; + + return site.getDb().insertRecord(AddonCalendarOfflineProvider.EVENTS_TABLE, event).then(() => { + return event; + }); + }); + } +} diff --git a/src/addon/calendar/providers/calendar-sync.ts b/src/addon/calendar/providers/calendar-sync.ts new file mode 100644 index 000000000..cb9eb4b63 --- /dev/null +++ b/src/addon/calendar/providers/calendar-sync.ts @@ -0,0 +1,230 @@ +// (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 { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreAppProvider } from '@providers/app'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonCalendarProvider } from './calendar'; +import { AddonCalendarOfflineProvider } from './calendar-offline'; + +/** + * Service to sync calendar. + */ +@Injectable() +export class AddonCalendarSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'addon_calendar_autom_synced'; + static MANUAL_SYNCED = 'addon_calendar_manual_synced'; + static SYNC_ID = 'calendar'; + + protected componentTranslate: string; + + constructor(translate: TranslateService, + appProvider: CoreAppProvider, + courseProvider: CoreCourseProvider, + private eventsProvider: CoreEventsProvider, + loggerProvider: CoreLoggerProvider, + sitesProvider: CoreSitesProvider, + syncProvider: CoreSyncProvider, + textUtils: CoreTextUtilsProvider, + timeUtils: CoreTimeUtilsProvider, + private utils: CoreUtilsProvider, + private calendarProvider: AddonCalendarProvider, + private calendarOffline: AddonCalendarOfflineProvider) { + + super('AddonCalendarSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate, + timeUtils); + + this.componentTranslate = this.translate.instant('addon.calendar.calendarevent'); + } + + /** + * Try to synchronize all events in a certain site or in all sites. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} [force] Wether to force sync not depending on last execution. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllEvents(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all calendars', this.syncAllEventsFunc.bind(this), [force], siteId); + } + + /** + * Sync all events on a site. + * + * @param {string} siteId Site ID to sync. + * @param {boolean} [force] Wether to force sync not depending on last execution. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + protected syncAllEventsFunc(siteId: string, force?: boolean): Promise { + const promise = force ? this.syncEvents(siteId) : this.syncEventsIfNeeded(siteId); + + return promise.then((result) => { + if (result && result.updated) { + // Sync successful, send event. + this.eventsProvider.trigger(AddonCalendarSyncProvider.AUTO_SYNCED, { + warnings: result.warnings, + events: result.events + }, siteId); + } + }); + } + + /** + * Sync a site events only if a certain time has passed since the last time. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the events are synced or if it doesn't need to be synced. + */ + syncEventsIfNeeded(siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.isSyncNeeded(AddonCalendarSyncProvider.SYNC_ID, siteId).then((needed) => { + if (needed) { + return this.syncEvents(siteId); + } + }); + } + + /** + * Synchronize all offline events of a certain site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + syncEvents(siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (this.isSyncing(AddonCalendarSyncProvider.SYNC_ID, siteId)) { + // There's already a sync ongoing for this site, return the promise. + return this.getOngoingSync(AddonCalendarSyncProvider.SYNC_ID, siteId); + } + + this.logger.debug('Try to sync calendar events for site ' + siteId); + + const result = { + warnings: [], + events: [], + updated: false + }; + let offlineEvents; + + // Get offline events. + const syncPromise = this.calendarOffline.getAllEvents(siteId).catch(() => { + // No offline data found, return empty list. + return []; + }).then((events) => { + offlineEvents = events; + + if (!events.length) { + // Nothing to sync. + return; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + const promises = []; + + events.forEach((event) => { + promises.push(this.syncOfflineEvent(event, result, siteId)); + }); + + return this.utils.allPromises(promises); + }).then(() => { + if (result.updated) { + // Data has been sent to server. Now invalidate the WS calls. + const promises = [ + this.calendarProvider.invalidateEventsList(siteId), + ]; + + offlineEvents.forEach((event) => { + if (event.id > 0) { + // An event was edited, invalidate its data too. + promises.push(this.calendarProvider.invalidateEvent(event.id, siteId)); + } + }); + + return Promise.all(promises).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(AddonCalendarSyncProvider.SYNC_ID, siteId).catch(() => { + // Ignore errors. + }); + }).then(() => { + // All done, return the result. + return result; + }); + + return this.addOngoingSync(AddonCalendarSyncProvider.SYNC_ID, syncPromise, siteId); + } + + /** + * Synchronize an offline event. + * + * @param {any} event The event to sync. + * @param {any} result Object where to store the result of the sync. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + protected syncOfflineEvent(event: any, result: any, siteId?: string): Promise { + + // Verify that event isn't blocked. + if (this.syncProvider.isBlocked(AddonCalendarProvider.COMPONENT, event.id, siteId)) { + this.logger.debug('Cannot sync event ' + event.name + ' because it is blocked.'); + + return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); + } + + // Try to send the data. + const data = this.utils.clone(event); // Clone the object because it will be modified in the submit function. + + return this.calendarProvider.submitEventOnline(event.id > 0 ? event.id : undefined, data, siteId).then((newEvent) => { + result.updated = true; + result.events.push(newEvent); + + // Event sent, delete the offline data. + return this.calendarOffline.deleteEvent(event.id, siteId); + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that the event cannot be created. Delete it. + result.updated = true; + + return this.calendarOffline.deleteEvent(event.id, siteId).then(() => { + // Event deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: event.name, + error: this.textUtils.getErrorMessageFromError(error) + })); + }); + } + + // Local error, reject. + return Promise.reject(error); + }); + } +} diff --git a/src/addon/calendar/providers/calendar.ts b/src/addon/calendar/providers/calendar.ts index 40dcd2208..6e5bc965b 100644 --- a/src/addon/calendar/providers/calendar.ts +++ b/src/addon/calendar/providers/calendar.ts @@ -17,13 +17,16 @@ import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; import { CoreSite } from '@classes/site'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; +import { CoreAppProvider } from '@providers/app'; 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'; import { CoreConfigProvider } from '@providers/config'; import { ILocalNotification } from '@ionic-native/local-notifications'; import { SQLiteDB } from '@classes/sqlitedb'; +import { AddonCalendarOfflineProvider } from './calendar-offline'; /** * Service to handle calendar events. @@ -35,6 +38,13 @@ 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 NEW_EVENT_DISCARDED_EVENT = 'addon_calendar_new_event_discarded'; + 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 +216,38 @@ 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, private calendarOffline: AddonCalendarOfflineProvider, + private appProvider: CoreAppProvider) { 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} Promise resolved with true if can create/edit. + */ + canEditEvents(siteId?: string): Promise { + 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 +292,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} Promise resolved with object with access information. + * @since 3.7 + */ + getAccessInformation(courseId?: number, siteId?: string): Promise { + 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 +337,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} Promise resolved with an object indicating the types. + * @since 3.7 + */ + getAllowedEventTypes(courseId?: number, siteId?: string): Promise { + 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 +618,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} Promise resolved when the data is invalidated. + */ + invalidateAccessInformation(courseId?: number, siteId?: string): Promise { + 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} Promise resolved when the data is invalidated. + */ + invalidateAllowedEventTypes(courseId?: number, siteId?: string): Promise { + 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 +920,79 @@ export class AddonCalendarProvider { })); }); } + + /** + * Submit a calendar event. + * + * @param {number} eventId ID of the event. If undefined/null, create a new event. + * @param {any} formData Form data. + * @param {number} [timeCreated] The time the event was created. Only if modifying a new offline event. + * @param {boolean} [forceOffline] True to always save it in offline. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{sent: boolean, event: any}>} Promise resolved with the event and a boolean indicating if data was + * sent to server or stored in offline. + */ + submitEvent(eventId: number, formData: any, timeCreated?: number, forceOffline?: boolean, siteId?: string): + Promise<{sent: boolean, event: any}> { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Function to store the event to be synchronized later. + const storeOffline = (): Promise<{sent: boolean, event: any}> => { + return this.calendarOffline.saveEvent(eventId, formData, timeCreated, siteId).then((event) => { + return {sent: false, event: event}; + }); + }; + + if (forceOffline || !this.appProvider.isOnline()) { + // App is offline, store the event. + return storeOffline(); + } + + // If the event is already stored, discard it first. + return this.calendarOffline.deleteEvent(eventId, siteId).then(() => { + return this.submitEventOnline(eventId, formData, siteId).then((event) => { + return {sent: true, event: event}; + }).catch((error) => { + if (error && !this.utils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } else { + // The WebService has thrown an error, reject. + return Promise.reject(error); + } + }); + }); + } + + /** + * Submit an event, either to create it or to edit it. It will fail if offline or cannot connect. + * + * @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} Promise resolved when done. + */ + submitEventOnline(eventId: number, formData: any, siteId?: string): Promise { + 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(this.utils.createFakeWSError('')); + } + + return result.event; + }); + }); + } } diff --git a/src/addon/calendar/providers/helper.ts b/src/addon/calendar/providers/helper.ts index a1a85759d..ae857225d 100644 --- a/src/addon/calendar/providers/helper.ts +++ b/src/addon/calendar/providers/helper.ts @@ -15,6 +15,8 @@ import { Injectable } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreCourseProvider } from '@core/course/providers/course'; +import { AddonCalendarProvider } from './calendar'; +import { CoreConstants } from '@core/constants'; /** * Service that provides some features regarding lists of courses and categories. @@ -31,10 +33,33 @@ export class AddonCalendarHelperProvider { category: 'fa-cubes' }; - constructor(logger: CoreLoggerProvider, private courseProvider: CoreCourseProvider) { + constructor(logger: CoreLoggerProvider, private courseProvider: CoreCourseProvider, + private calendarProvider: AddonCalendarProvider) { this.logger = logger.getInstance('AddonCalendarHelperProvider'); } + /** + * Check if current user can create/edit events. + * + * @param {number} [courseId] Course ID. If not defined, site calendar. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: whether the user can create events. + */ + canEditEvents(courseId?: number, siteId?: string): Promise { + return this.calendarProvider.canEditEvents(siteId).then((canEdit) => { + if (!canEdit) { + return false; + } + + // Site allows creating events. Check if the user has permissions to do so. + return this.calendarProvider.getAllowedEventTypes(courseId, siteId).then((types) => { + return Object.keys(types).length > 0; + }); + }).catch(() => { + return false; + }); + } + /** * Convenience function to format some event data to be rendered. * @@ -46,5 +71,88 @@ export class AddonCalendarHelperProvider { e.icon = this.courseProvider.getModuleIconSrc(e.modulename); e.moduleIcon = e.icon; } + + if (e.id < 0) { + // It's an offline event, add some calculated data. + e.format = 1; + e.visible = 1; + + if (e.duration == 1) { + e.timeduration = e.timedurationuntil - e.timestart; + } else if (e.duration == 2) { + e.timeduration = e.timedurationminutes * CoreConstants.SECONDS_MINUTE; + } else { + e.timeduration = 0; + } + } + } + + /** + * 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; } } diff --git a/src/addon/calendar/providers/sync-cron-handler.ts b/src/addon/calendar/providers/sync-cron-handler.ts new file mode 100644 index 000000000..6aabcca74 --- /dev/null +++ b/src/addon/calendar/providers/sync-cron-handler.ts @@ -0,0 +1,48 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@providers/cron'; +import { AddonCalendarSyncProvider } from './calendar-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonCalendarSyncCronHandler implements CoreCronHandler { + name = 'AddonCalendarSyncCronHandler'; + + constructor(private calendarSync: AddonCalendarSyncProvider) {} + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). + * @return {Promise} Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return this.calendarSync.syncAllEvents(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return this.calendarSync.syncInterval; + } +} diff --git a/src/addon/mod/data/fields/date/component/date.ts b/src/addon/mod/data/fields/date/component/date.ts index ff0ab6ded..c846032a8 100644 --- a/src/addon/mod/data/fields/date/component/date.ts +++ b/src/addon/mod/data/fields/date/component/date.ts @@ -51,10 +51,10 @@ export class AddonModDataFieldDateComponent extends AddonModDataFieldPluginCompo val = this.search['f_' + this.field.id + '_y'] ? new Date(this.search['f_' + this.field.id + '_y'] + '-' + this.search['f_' + this.field.id + '_m'] + '-' + this.search['f_' + this.field.id + '_d']) : new Date(); - this.search['f_' + this.field.id] = val.toISOString(); + this.search['f_' + this.field.id] = this.timeUtils.toDatetimeFormat(val.getTime()); } else { val = this.value && this.value.content ? new Date(parseInt(this.value.content, 10) * 1000) : new Date(); - val = val.toISOString(); + val = this.timeUtils.toDatetimeFormat(val.getTime()); } this.addControl('f_' + this.field.id, val); diff --git a/src/addon/mod/data/fields/date/providers/handler.ts b/src/addon/mod/data/fields/date/providers/handler.ts index f36d166b7..bf9d65079 100644 --- a/src/addon/mod/data/fields/date/providers/handler.ts +++ b/src/addon/mod/data/fields/date/providers/handler.ts @@ -15,6 +15,7 @@ import { Injector, Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { AddonModDataFieldHandler } from '../../../providers/fields-delegate'; import { AddonModDataFieldDateComponent } from '../component/date'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; /** * Handler for date data field plugin. @@ -24,7 +25,7 @@ export class AddonModDataFieldDateHandler implements AddonModDataFieldHandler { name = 'AddonModDataFieldDateHandler'; type = 'date'; - constructor(private translate: TranslateService) { } + constructor(private translate: TranslateService, private timeUtils: CoreTimeUtilsProvider) { } /** * Return the Component to use to display the plugin data. @@ -129,7 +130,7 @@ export class AddonModDataFieldDateHandler implements AddonModDataFieldHandler { input = inputData[fieldName] && inputData[fieldName].substr(0, 10) || ''; originalFieldData = (originalFieldData && originalFieldData.content && - new Date(originalFieldData.content * 1000).toISOString().substr(0, 10)) || ''; + this.timeUtils.toDatetimeFormat(originalFieldData.content * 1000).substr(0, 10)) || ''; return input != originalFieldData; } diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 5b481b28a..f29201b76 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -84,16 +84,30 @@ "addon.blog.showonlyyourentries": "Show only your entries", "addon.blog.siteblogheading": "Site blog", "addon.calendar.calendar": "Calendar", + "addon.calendar.calendarevent": "Calendar event", "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", @@ -1335,6 +1349,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", @@ -1465,6 +1480,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.", @@ -1631,6 +1647,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", @@ -1699,6 +1716,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", @@ -1767,6 +1787,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", @@ -1815,6 +1836,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", diff --git a/src/lang/en.json b/src/lang/en.json index 3aceb76c7..7dd93bcf8 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -48,6 +48,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", @@ -102,6 +103,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.", @@ -171,6 +173,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", @@ -211,10 +214,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", @@ -261,6 +268,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", diff --git a/src/providers/utils/time.ts b/src/providers/utils/time.ts index f7c2de9f6..6e2e00aa7 100644 --- a/src/providers/utils/time.ts +++ b/src/providers/utils/time.ts @@ -299,6 +299,33 @@ export class CoreTimeUtilsProvider { return moment(timestamp).format(format); } + /** + * Convert a timestamp to the format to set to a datetime input. + * + * @param {number} [timestamp] Timestamp to convert (in ms). If not provided, current time. + * @return {string} Formatted time. + */ + toDatetimeFormat(timestamp?: number): string { + timestamp = timestamp || Date.now(); + + return this.userDate(timestamp, 'YYYY-MM-DDTHH:mm:ss.SSS', false); + } + + /** + * Convert the value of a ion-datetime to a Date. + * + * @param {string} value Value of ion-datetime. + * @return {Date} Date. + */ + datetimeToDate(value: string): Date { + if (typeof value == 'string' && value.slice(-1) == 'Z') { + // The value shoudln't have the timezone because it causes problems, remove it. + value = value.substr(0, value.length - 1); + } + + return new Date(value); + } + /** * Convert a text into user timezone timestamp. * diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index 77867c500..a0c352bfd 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -403,13 +403,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) { @@ -425,7 +427,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; @@ -1078,6 +1081,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. *