diff --git a/src/addon/mod/data/classes/field-plugin-component.ts b/src/addon/mod/data/classes/field-plugin-component.ts new file mode 100644 index 000000000..dcd853b82 --- /dev/null +++ b/src/addon/mod/data/classes/field-plugin-component.ts @@ -0,0 +1,28 @@ +// (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 { Input } from '@angular/core'; + +/** + * Base class for component to render a field. + */ +export class AddonModDataFieldPluginComponent { + @Input() mode: string; // The render mode. + @Input() field: any; // The field to render. + @Input() value?: any; // The value of the field. + @Input() database?: any; // Database object. + @Input() error?: string; // Error when editing. + @Input() viewAction: string; // Action to perform. + + constructor() { } +} diff --git a/src/addon/mod/data/components/components.module.ts b/src/addon/mod/data/components/components.module.ts new file mode 100644 index 000000000..7b979b396 --- /dev/null +++ b/src/addon/mod/data/components/components.module.ts @@ -0,0 +1,50 @@ +// (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 { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreCourseComponentsModule } from '@core/course/components/components.module'; +import { AddonModDataIndexComponent } from './index/index'; +import { AddonModDataFieldPluginComponent } from './field-plugin/field-plugin'; +import { CoreCompileHtmlComponentModule } from '@core/compile/components/compile-html/compile-html.module'; + +@NgModule({ + declarations: [ + AddonModDataIndexComponent, + AddonModDataFieldPluginComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCourseComponentsModule, + CoreCompileHtmlComponentModule + ], + providers: [ + ], + exports: [ + AddonModDataIndexComponent, + AddonModDataFieldPluginComponent + ], + entryComponents: [ + AddonModDataIndexComponent + ] +}) +export class AddonModDataComponentsModule {} diff --git a/src/addon/mod/data/components/field-plugin/field-plugin.html b/src/addon/mod/data/components/field-plugin/field-plugin.html new file mode 100644 index 000000000..489bbda21 --- /dev/null +++ b/src/addon/mod/data/components/field-plugin/field-plugin.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/addon/mod/data/components/field-plugin/field-plugin.ts b/src/addon/mod/data/components/field-plugin/field-plugin.ts new file mode 100644 index 000000000..819d43e2d --- /dev/null +++ b/src/addon/mod/data/components/field-plugin/field-plugin.ts @@ -0,0 +1,82 @@ +// (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, Input, OnInit, Injector, ViewChild } from '@angular/core'; +import { AddonModDataProvider } from '../../providers/data'; +import { AddonModDataFieldsDelegate } from '../../providers/fields-delegate'; +import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; + +/** + * Component that displays an assignment feedback plugin. + */ +@Component({ + selector: 'addon-mod-data-field-plugin', + templateUrl: 'field-plugin.html', +}) +export class AddonModDataFieldPluginComponent implements OnInit { + @ViewChild(CoreDynamicComponent) dynamicComponent: CoreDynamicComponent; + + @Input() mode: string; // The render mode. + @Input() field: any; // The field to render. + @Input() value?: any; // The value of the field. + @Input() database?: any; // Database object. + @Input() error?: string; // Error when editing. + @Input() viewAction: string; // Action to perform. + + fieldComponent: any; // Component to render the plugin. + data: any; // Data to pass to the component. + fieldLoaded: boolean; + + constructor(protected injector: Injector, protected dataDelegate: AddonModDataFieldsDelegate, + protected dataProvider: AddonModDataProvider) { } + + /** + * Component being initialized. + */ + ngOnInit(): void { + console.error('HERE'); + if (!this.field) { + this.fieldLoaded = true; + + return; + } + + // Check if the plugin has defined its own component to render itself. + this.dataDelegate.getComponentForField(this.injector, this.field).then((component) => { + this.fieldComponent = component; + + if (component) { + // Prepare the data to pass to the component. + this.data = { + mode: this.mode, + field: this.field, + value: this.value, + database: this.database, + error: this.error, + viewAction: this.viewAction + }; + } else { + this.fieldLoaded = true; + } + }); + } + + /** + * Invalidate the plugin data. + * + * @return {Promise} Promise resolved when done. + */ + invalidate(): Promise { + return Promise.resolve(this.dynamicComponent && this.dynamicComponent.callComponentFunction('invalidate', [])); + } +} diff --git a/src/addon/mod/data/components/index/index.html b/src/addon/mod/data/components/index/index.html new file mode 100644 index 000000000..e53c61d7a --- /dev/null +++ b/src/addon/mod/data/components/index/index.html @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + +
+ + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} +
+ + + {{ 'core.groupsseparate' | translate }} + {{ 'core.groupsvisible' | translate }} + + {{groupOpt.name}} + + + +
+ + {{ 'addon.mod_data.notopenyet' | translate:{$a: timeAvailableFromReadable} }} +
+ +
+ + {{ 'addon.mod_data.expired' | translate:{$a: timeAvailableToReadable} }} +
+ +
+ + {{ 'addon.mod_data.entrieslefttoaddtoview' | translate:{$a: {entrieslefttoview: access.entrieslefttoview} } }} +
+ +
+ + {{ 'addon.mod_data.entrieslefttoadd' | translate:{$a: {entriesleft: access.entrieslefttoadd} } }} +
+ + + {{ 'addon.mod_data.resetsettings' | translate}} + + +
+ + + +
+ + + + + + + + + + + + + +
+ +
+
+ + + {{ 'addon.mod_data.resetsettings' | translate}} + + + +
diff --git a/src/addon/mod/data/components/index/index.scss b/src/addon/mod/data/components/index/index.scss new file mode 100644 index 000000000..1a748d124 --- /dev/null +++ b/src/addon/mod/data/components/index/index.scss @@ -0,0 +1,3 @@ +addon-mod-data-index { + +} \ No newline at end of file diff --git a/src/addon/mod/data/components/index/index.ts b/src/addon/mod/data/components/index/index.ts new file mode 100644 index 000000000..713731112 --- /dev/null +++ b/src/addon/mod/data/components/index/index.ts @@ -0,0 +1,454 @@ +// (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, Optional, Injector } from '@angular/core'; +import { Content, ModalController } from 'ionic-angular'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreGroupsProvider, CoreGroupInfo } from '@providers/groups'; +import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; +import { CoreCommentsProvider } from '@core/comments/providers/comments'; +import { AddonModDataProvider } from '../../providers/data'; +import { AddonModDataHelperProvider } from '../../providers/helper'; +import { AddonModDataOfflineProvider } from '../../providers/offline'; +import { AddonModDataSyncProvider } from '../../providers/sync'; +import * as moment from 'moment'; + +/** + * Component that displays a data index page. + */ +@Component({ + selector: 'addon-mod-data-index', + templateUrl: 'index.html', +}) +export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComponent { + component = AddonModDataProvider.COMPONENT; + moduleName = 'data'; + + access: any = {}; + data: any = {}; + fields: any; + selectedGroup: number; + advancedSearch: any; + timeAvailableFrom: number | boolean; + timeAvailableFromReadable: string | boolean; + timeAvailableTo: number | boolean; + timeAvailableToReadable: string | boolean; + isEmpty = false; + groupInfo: CoreGroupInfo; + entries = {}; + firstEntry = false; + canAdd = false; + canSearch = false; + search = { + sortBy: '0', + sortDirection: 'DESC', + page: 0, + text: '', + searching: false, + searchingAdvanced: false, + advanced: [] + }; + hasNextPage = false; + offlineActions: any; + offlineEntries: any; + entriesRendered = ''; + cssTemplate = ''; + + protected syncEventName = AddonModDataSyncProvider.AUTO_SYNCED; + protected entryChangedObserver: any; + protected hasComments = false; + + constructor(injector: Injector, private dataProvider: AddonModDataProvider, private dataHelper: AddonModDataHelperProvider, + private dataOffline: AddonModDataOfflineProvider, @Optional() private content: Content, + private dataSync: AddonModDataSyncProvider, private timeUtils: CoreTimeUtilsProvider, + private groupsProvider: CoreGroupsProvider, private commentsProvider: CoreCommentsProvider, + private modalCtrl: ModalController, private utils: CoreUtilsProvider) { + super(injector); + + // Refresh entries on change. + this.entryChangedObserver = this.eventsProvider.on(AddonModDataProvider.ENTRY_CHANGED, (eventData) => { + if (this.data.id == eventData.dataId) { + this.loaded = false; + + return this.loadContent(true); + } + }, this.siteId); + } + /** + * Component being initialized. + */ + ngOnInit(): void { + super.ngOnInit(); + + this.selectedGroup = this.group || 0; + + this.loadContent(false, true).then(() => { + this.dataProvider.logView(this.data.id).then(() => { + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + }); + }); + + // Setup search modal. + } + + /** + * Perform the invalidate content function. + * + * @return {Promise} Resolved when done. + */ + protected invalidateContent(): Promise { + const promises = []; + + promises.push(this.dataProvider.invalidateDatabaseData(this.courseId)); + if (this.data) { + promises.push(this.dataProvider.invalidateDatabaseAccessInformationData(this.data.id)); + promises.push(this.groupsProvider.invalidateActivityGroupInfo(this.data.coursemodule)); + promises.push(this.dataProvider.invalidateEntriesData(this.data.id)); + if (this.hasComments) { + promises.push(this.commentsProvider.invalidateCommentsByInstance('module', this.data.coursemodule)); + } + } + + return Promise.all(promises); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param {any} syncEventData Data receiven on sync observer. + * @return {boolean} True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: any): boolean { + if (this.data && syncEventData.dataId == this.data.id && typeof syncEventData.entryId == 'undefined') { + this.loaded = false; + // Refresh the data. + this.content.scrollToTop(); + + return true; + } + + return false; + } + + /** + * Download data contents. + * + * @param {boolean} [refresh=false] If it's refreshing content. + * @param {boolean} [sync=false] If the refresh is needs syncing. + * @param {boolean} [showErrors=false] If show errors to the user of hide them. + * @return {Promise} Promise resolved when done. + */ + protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + let canAdd = false, + canSearch = false; + + return this.dataProvider.getDatabase(this.courseId, this.module.id).then((data) => { + this.data = data; + + this.description = data.intro || data.description; + this.dataRetrieved.emit(data); + + if (sync) { + // Try to synchronize the data. + return this.syncActivity(showErrors).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + return this.dataProvider.getDatabaseAccessInformation(this.data.id); + }).then((accessData) => { + this.access = accessData; + + if (!accessData.timeavailable) { + const time = this.timeUtils.timestamp(); + + this.timeAvailableFrom = this.data.timeavailablefrom && time < this.data.timeavailablefrom ? + parseInt(this.data.timeavailablefrom, 10) * 1000 : false; + this.timeAvailableFromReadable = this.timeAvailableFrom ? + moment(this.timeAvailableFrom).format('LLL') : false; + this.timeAvailableTo = this.data.timeavailableto && time > this.data.timeavailableto ? + parseInt(this.data.timeavailableto, 10) * 1000 : false; + this.timeAvailableToReadable = this.timeAvailableTo ? moment(this.timeAvailableTo).format('LLL') : false; + + this.isEmpty = true; + this.groupInfo = null; + + return; + } + + canSearch = true; + canAdd = accessData.canaddentry; + + return this.groupsProvider.getActivityGroupInfo(this.data.coursemodule, accessData.canmanageentries) + .then((groupInfo) => { + this.groupInfo = groupInfo; + + // Check selected group is accessible. + if (groupInfo && groupInfo.groups && groupInfo.groups.length > 0) { + if (!groupInfo.groups.some((group) => this.selectedGroup == group.id)) { + this.selectedGroup = groupInfo.groups[0].id; + } + } + + return this.fetchOfflineEntries(); + }); + }).then(() => { + return this.dataProvider.getFields(this.data.id).then((fields) => { + if (fields.length == 0) { + canSearch = false; + canAdd = false; + } + this.search.advanced = []; + + this.fields = {}; + fields.forEach((field) => { + this.fields[field.id] = field; + }); + this.fields = this.utils.objectToArray(this.fields); + this.advancedSearch = this.dataHelper.displayAdvancedSearchFields(this.data.asearchtemplate, this.fields); + + return this.fetchEntriesData(); + }); + }).then(() => { + // All data obtained, now fill the context menu. + this.fillContextMenu(refresh); + }).finally(() => { + this.canAdd = canAdd; + this.canSearch = canSearch; + }); + } + + /** + * Fetch current database entries. + * + * @return {Promise} Resolved then done. + */ + protected fetchEntriesData(): Promise { + this.hasComments = false; + + return this.dataProvider.getDatabaseAccessInformation(this.data.id, this.selectedGroup).then((accessData) => { + // Update values for current group. + this.access.canaddentry = accessData.canaddentry; + + if (this.search.searching) { + const text = this.search.searchingAdvanced ? undefined : this.search.text, + advanced = this.search.searchingAdvanced ? this.search.advanced : undefined; + + return this.dataProvider.searchEntries(this.data.id, this.selectedGroup, text, advanced, this.search.sortBy, + this.search.sortDirection, this.search.page); + } else { + return this.dataProvider.getEntries(this.data.id, this.selectedGroup, this.search.sortBy, this.search.sortDirection, + this.search.page); + } + }).then((entries) => { + const numEntries = (entries && entries.entries && entries.entries.length) || 0; + this.isEmpty = !numEntries && !Object.keys(this.offlineActions).length && !Object.keys(this.offlineEntries).length; + this.hasNextPage = numEntries >= AddonModDataProvider.PER_PAGE && ((this.search.page + 1) * + AddonModDataProvider.PER_PAGE) < entries.totalcount; + this.entriesRendered = ''; + + if (!this.isEmpty) { + this.cssTemplate = this.dataHelper.prefixCSS(this.data.csstemplate, '.addon-data-entries-' + this.data.id); + + const siteInfo = this.sitesProvider.getCurrentSite().getInfo(), + promises = []; + + this.utils.objectToArray(this.offlineEntries).forEach((offlineActions) => { + const offlineEntry = offlineActions.find((offlineEntry) => offlineEntry.action == 'add'); + + if (offlineEntry) { + const entry = { + id: offlineEntry.entryid, + canmanageentry: true, + approved: !this.data.approval || this.data.manageapproved, + dataid: offlineEntry.dataid, + groupid: offlineEntry.groupid, + timecreated: -offlineEntry.entryid, + timemodified: -offlineEntry.entryid, + userid: siteInfo.userid, + fullname: siteInfo.fullname, + contents: {} + }; + + if (offlineActions.length > 0) { + promises.push(this.dataHelper.applyOfflineActions(entry, offlineActions, this.fields)); + } else { + promises.push(Promise.resolve(entry)); + } + } + }); + + entries.entries.forEach((entry) => { + // Index contents by fieldid. + const contents = {}; + entry.contents.forEach((field) => { + contents[field.fieldid] = field; + }); + entry.contents = contents; + + if (typeof this.offlineActions[entry.id] != 'undefined') { + promises.push(this.dataHelper.applyOfflineActions(entry, this.offlineActions[entry.id], this.fields)); + } else { + promises.push(Promise.resolve(entry)); + } + }); + + return Promise.all(promises).then((entries) => { + let entriesHTML = this.data.listtemplateheader || ''; + + // Get first entry from the whole list. + if (entries && entries[0] && (!this.search.searching || !this.firstEntry)) { + this.firstEntry = entries[0].id; + } + + entries.forEach((entry) => { + this.entries[entry.id] = entry; + + const actions = this.dataHelper.getActions(this.data, this.access, entry); + + entriesHTML += this.dataHelper.displayShowFields(this.data.listtemplate, this.fields, entry, 'list', + actions); + }); + entriesHTML += this.data.listtemplatefooter || ''; + + this.entriesRendered = entriesHTML; + }); + } else if (!this.search.searching) { + // Empty and no searching. + this.canSearch = false; + } + this.firstEntry = false; + }); + } + + /** + * Display the chat users modal. + */ + showSearch(): void { + const modal = this.modalCtrl.create('AddonModDataSearchPage'); + modal.onDidDismiss((data) => { + // @TODO. + }); + modal.present(); + } + + /** + * Performs the search and closes the modal. + * + * @param {number} page Page number. + * @return {Promise} Resolved when done. + */ + searchEntries(page: number): Promise { + this.loaded = false; + this.search.page = page; + + if (this.search.searchingAdvanced) { + this.search.advanced = this.dataHelper.getSearchDataFromForm(document.forms['addon-mod_data-advanced-search-form'], + this.fields); + this.search.searching = this.search.advanced.length > 0; + } else { + this.search.searching = this.search.text.length > 0; + } + + return this.fetchEntriesData().catch((message) => { + this.domUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Reset all search filters and closes the modal. + */ + searchReset(): void { + this.search.sortBy = '0'; + this.search.sortDirection = 'DESC'; + this.search.text = ''; + this.search.advanced = []; + this.search.searchingAdvanced = false; + this.search.searching = false; + this.searchEntries(0); + } + + // Set group to see the database. + setGroup(groupId: number): Promise { + this.selectedGroup = groupId; + + return this.fetchEntriesData().catch((message) => { + this.domUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); + + return Promise.reject(null); + }); + } + + /** + * Fetch offline entries. + * + * @return {Promise} Resolved then done. + */ + protected fetchOfflineEntries(): Promise { + // Check if there are entries stored in offline. + return this.dataOffline.getDatabaseEntries(this.data.id).then((offlineEntries) => { + this.hasOffline = !!offlineEntries.length; + + this.offlineActions = {}; + this.offlineEntries = {}; + + // Only show offline entries on first page. + if (this.search.page == 0 && this.hasOffline) { + offlineEntries.forEach((entry) => { + if (entry.entryid > 0) { + if (typeof this.offlineActions[entry.entryid] == 'undefined') { + this.offlineActions[entry.entryid] = []; + } + this.offlineActions[entry.entryid].push(entry); + } else { + if (typeof this.offlineActions[entry.entryid] == 'undefined') { + this.offlineEntries[entry.entryid] = []; + } + this.offlineEntries[entry.entryid].push(entry); + } + }); + } + }); + } + + /** + * Performs the sync of the activity. + * + * @return {Promise} Promise resolved when done. + */ + protected sync(): Promise { + return this.dataSync.syncDatabase(this.data.id); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param {any} result Data returned on the sync function. + * @return {boolean} If suceed or not. + */ + protected hasSyncSucceed(result: any): boolean { + return result.updated; + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + this.entryChangedObserver && this.entryChangedObserver.off(); + } +} diff --git a/src/addon/mod/data/data.module.ts b/src/addon/mod/data/data.module.ts new file mode 100644 index 000000000..12f6eb73b --- /dev/null +++ b/src/addon/mod/data/data.module.ts @@ -0,0 +1,63 @@ +// (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 { CoreCronDelegate } from '@providers/cron'; +import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { AddonModDataComponentsModule } from './components/components.module'; +import { AddonModDataModuleHandler } from './providers/module-handler'; +import { AddonModDataProvider } from './providers/data'; +import { AddonModDataLinkHandler } from './providers/link-handler'; +import { AddonModDataHelperProvider } from './providers/helper'; +import { AddonModDataPrefetchHandler } from './providers/prefetch-handler'; +import { AddonModDataSyncProvider } from './providers/sync'; +import { AddonModDataSyncCronHandler } from './providers/sync-cron-handler'; +import { AddonModDataOfflineProvider } from './providers/offline'; +import { AddonModDataFieldsDelegate } from './providers/fields-delegate'; +import { AddonModDataDefaultFieldHandler } from './providers/default-field-handler'; +import { AddonModDataFieldModule } from './fields/field.module'; + +@NgModule({ + declarations: [ + ], + imports: [ + AddonModDataComponentsModule, + AddonModDataFieldModule + ], + providers: [ + AddonModDataProvider, + AddonModDataModuleHandler, + AddonModDataPrefetchHandler, + AddonModDataHelperProvider, + AddonModDataLinkHandler, + AddonModDataSyncCronHandler, + AddonModDataSyncProvider, + AddonModDataOfflineProvider, + AddonModDataFieldsDelegate, + AddonModDataDefaultFieldHandler + ] +}) +export class AddonModDataModule { + constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModDataModuleHandler, + prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModDataPrefetchHandler, + contentLinksDelegate: CoreContentLinksDelegate, linkHandler: AddonModDataLinkHandler, + cronDelegate: CoreCronDelegate, syncHandler: AddonModDataSyncCronHandler) { + moduleDelegate.registerHandler(moduleHandler); + prefetchDelegate.registerHandler(prefetchHandler); + contentLinksDelegate.registerHandler(linkHandler); + cronDelegate.register(syncHandler); + } +} diff --git a/src/addon/mod/data/fields/checkbox/checkbox.module.ts b/src/addon/mod/data/fields/checkbox/checkbox.module.ts new file mode 100644 index 000000000..5ac3377f2 --- /dev/null +++ b/src/addon/mod/data/fields/checkbox/checkbox.module.ts @@ -0,0 +1,49 @@ +// (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 { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { AddonModDataFieldCheckboxHandler } from './providers/handler'; +import { AddonModDataFieldsDelegate } from '../../providers/fields-delegate'; +import { AddonModDataFieldCheckboxComponent } from './component/checkbox'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + AddonModDataFieldCheckboxComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule + ], + providers: [ + AddonModDataFieldCheckboxHandler + ], + exports: [ + AddonModDataFieldCheckboxComponent + ], + entryComponents: [ + AddonModDataFieldCheckboxComponent + ] +}) +export class AddonModDataFieldCheckboxModule { + constructor(fieldDelegate: AddonModDataFieldsDelegate, handler: AddonModDataFieldCheckboxHandler) { + fieldDelegate.registerHandler(handler); + } +} diff --git a/src/addon/mod/data/fields/checkbox/component/checkbox.html b/src/addon/mod/data/fields/checkbox/component/checkbox.html new file mode 100644 index 000000000..cc37e6f1e --- /dev/null +++ b/src/addon/mod/data/fields/checkbox/component/checkbox.html @@ -0,0 +1,16 @@ + + + + {{ option }} + + + + + + + {{ 'addon.mod_data.selectedrequired' | translate }} + + + + + \ No newline at end of file diff --git a/src/addon/mod/data/fields/checkbox/component/checkbox.ts b/src/addon/mod/data/fields/checkbox/component/checkbox.ts new file mode 100644 index 000000000..64f4db6e2 --- /dev/null +++ b/src/addon/mod/data/fields/checkbox/component/checkbox.ts @@ -0,0 +1,66 @@ +// (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, ElementRef } from '@angular/core'; +import { FormBuilder, FormControl } from '@angular/forms'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { AddonModDataFieldPluginComponent } from '../../../classes/field-plugin-component'; + +/** + * Component to render data checkbox field. + */ +@Component({ + selector: 'addon-mod-data-field-checkbox', + templateUrl: 'checkbox.html' +}) +export class AddonModDataFieldCheckboxComponent extends AddonModDataFieldPluginComponent implements OnInit { + + control: FormControl; + options: number; + values = {}; + + constructor(protected fb: FormBuilder, protected domUtils: CoreDomUtilsProvider, protected textUtils: CoreTextUtilsProvider, + element: ElementRef) { + + super(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.mode = this.mode == 'list' ? 'show' : this.mode; + this.render(); + } + + protected render(): void { + if (this.mode == 'show') { + this.value.content.split('##').join('
'); + + return; + } + + this.options = this.field.param1.split('\n'); + + if (this.mode == 'edit' && this.value) { + this.values = {}; + + this.value.content.split('##').forEach((value) => { + this.values[value] = true; + }); + + //this.control = this.fb.control(text); + } + } +} diff --git a/src/addon/mod/data/fields/checkbox/providers/handler.ts b/src/addon/mod/data/fields/checkbox/providers/handler.ts new file mode 100644 index 000000000..d49a0a3c0 --- /dev/null +++ b/src/addon/mod/data/fields/checkbox/providers/handler.ts @@ -0,0 +1,165 @@ +// (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 { Injector, Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { AddonModDataFieldHandler } from '../../../providers/fields-delegate'; +import { AddonModDataFieldCheckboxComponent } from '../component/checkbox'; + +/** + * Handler for checkbox data field plugin. + */ +@Injectable() +export class AddonModDataFieldCheckboxHandler implements AddonModDataFieldHandler { + name = 'AddonModDataFieldCheckboxHandler'; + type = 'checkbox'; + + constructor(private translate: TranslateService) { } + + /** + * Return the Component to use to display the plugin data. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} field The field object. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector, plugin: any): any | Promise { + console.error(AddonModDataFieldCheckboxComponent); + return AddonModDataFieldCheckboxComponent; + } + + /** + * Get field search data in the input data. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the search form. + * @return {any} With name and value of the data to be sent. + */ + getFieldSearchData(field: any, inputData: any): any { + const fieldName = 'f_' + field.id, + reqName = 'f_' + field.id + '_allreq'; + + const checkboxes = [], + values = []; + inputData[fieldName].forEach((value, option) => { + if (value) { + checkboxes.push(option); + } + }); + if (checkboxes.length > 0) { + values.push({ + name: fieldName, + value: checkboxes + }); + + if (inputData[reqName]['1']) { + values.push({ + name: reqName, + value: true + }); + } + + return values; + } + + return false; + } + + /** + * Get field edit data in the input data. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the edit form. + * @return {any} With name and value of the data to be sent. + */ + getFieldEditData(field: any, inputData: any, originalFieldData: any): any { + const fieldName = 'f_' + field.id; + + const checkboxes = []; + inputData[fieldName].forEach((value, option) => { + if (value) { + checkboxes.push(option); + } + }); + if (checkboxes.length > 0) { + return [{ + fieldid: field.id, + value: checkboxes + }]; + } + + return false; + } + + /** + * Get field data in changed. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the edit form. + * @param {any} originalFieldData Original field entered data. + * @return {Promise | boolean} If the field has changes. + */ + hasFieldDataChanged(field: any, inputData: any, originalFieldData: any): Promise | boolean { + const fieldName = 'f_' + field.id, + checkboxes = []; + + inputData[fieldName].forEach((value, option) => { + if (value) { + checkboxes.push(option); + } + }); + + originalFieldData = (originalFieldData && originalFieldData.content) || ''; + + return checkboxes.join('##') != originalFieldData; + } + + /** + * Check and get field requeriments. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the edit form. + * @return {string | false} String with the notification or false. + */ + getFieldsNotifications(field: any, inputData: any): string | false { + if (field.required && (!inputData || !inputData.length || !inputData[0].value)) { + return this.translate.instant('addon.mod_data.errormustsupplyvalue'); + } + + return false; + } + + /** + * Override field content data with offline submission. + * + * @param {any} originalContent Original data to be overriden. + * @param {any} offlineContent Array with all the offline data to override. + * @param {any} [offlineFiles] Array with all the offline files in the field. + * @return {any} Data overriden + */ + overrideData(originalContent: any, offlineContent: any, offlineFiles?: any): any { + originalContent.content = (offlineContent[''] && offlineContent[''].join('##')) || ''; + + return originalContent; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } +} diff --git a/src/addon/mod/data/fields/field.module.ts b/src/addon/mod/data/fields/field.module.ts new file mode 100644 index 000000000..c38ed2f56 --- /dev/null +++ b/src/addon/mod/data/fields/field.module.ts @@ -0,0 +1,27 @@ +// (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 { AddonModDataFieldCheckboxModule } from './checkbox/checkbox.module'; + +@NgModule({ + declarations: [], + imports: [ + AddonModDataFieldCheckboxModule + ], + providers: [ + ], + exports: [] +}) +export class AddonModDataFieldModule { } diff --git a/src/addon/mod/data/lang/en.json b/src/addon/mod/data/lang/en.json new file mode 100644 index 000000000..0e0dcd235 --- /dev/null +++ b/src/addon/mod/data/lang/en.json @@ -0,0 +1,3 @@ +{ + +} \ No newline at end of file diff --git a/src/addon/mod/data/pages/index/index.html b/src/addon/mod/data/pages/index/index.html new file mode 100644 index 000000000..ff39e903d --- /dev/null +++ b/src/addon/mod/data/pages/index/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/addon/mod/data/pages/index/index.module.ts b/src/addon/mod/data/pages/index/index.module.ts new file mode 100644 index 000000000..0215fd3f4 --- /dev/null +++ b/src/addon/mod/data/pages/index/index.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 { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModDataComponentsModule } from '../../components/components.module'; +import { AddonModDataIndexPage } from './index'; + +@NgModule({ + declarations: [ + AddonModDataIndexPage, + ], + imports: [ + CoreDirectivesModule, + AddonModDataComponentsModule, + IonicPageModule.forChild(AddonModDataIndexPage), + TranslateModule.forChild() + ], +}) +export class AddonModDataIndexPageModule {} diff --git a/src/addon/mod/data/pages/index/index.ts b/src/addon/mod/data/pages/index/index.ts new file mode 100644 index 000000000..f415352a4 --- /dev/null +++ b/src/addon/mod/data/pages/index/index.ts @@ -0,0 +1,50 @@ +// (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, ViewChild } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { AddonModDataIndexComponent } from '../../components/index/index'; + +/** + * Page that displays a data. + */ +@IonicPage({ segment: 'addon-mod-data-index' }) +@Component({ + selector: 'page-addon-mod-data-index', + templateUrl: 'index.html', +}) +export class AddonModDataIndexPage { + @ViewChild(AddonModDataIndexComponent) dataComponent: AddonModDataIndexComponent; + + title: string; + module: any; + courseId: number; + group: number; + + constructor(navParams: NavParams) { + this.module = navParams.get('module') || {}; + this.courseId = navParams.get('courseId'); + this.group = navParams.get('group') || 0; + this.title = this.module.name; + } + + /** + * Update some data based on the data instance. + * + * @param {any} data Data instance. + */ + updateData(data: any): void { + this.title = data.name || this.title; + } +} diff --git a/src/addon/mod/data/providers/data.ts b/src/addon/mod/data/providers/data.ts new file mode 100644 index 000000000..5ffa8fdaa --- /dev/null +++ b/src/addon/mod/data/providers/data.ts @@ -0,0 +1,573 @@ +// (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 { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreFilepoolProvider } from '@providers/filepool'; +import { AddonModDataOfflineProvider } from './offline'; + +/** + * Service that provides some features for databases. + */ +@Injectable() +export class AddonModDataProvider { + static COMPONENT = 'mmaModData'; + static PER_PAGE = 25; + static ENTRY_CHANGED = 'addon_mod_data_entry_changed'; + + protected ROOT_CACHE_KEY = AddonModDataProvider.COMPONENT + ':'; + protected logger; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, + private filepoolProvider: CoreFilepoolProvider, private dataOffline: AddonModDataOfflineProvider) { + this.logger = logger.getInstance('AddonModDataProvider'); + } + + /** + * Adds a new entry to a database. It does not cache calls. It will fail if offline or cannot connect. + * + * @param {number} dataId Database ID. + * @param {any} data The fields data to be created. + * @param {number} [groupId] Group id, 0 means that the function will determine the user group. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the action is done. + */ + addEntryOnline(dataId: number, data: any, groupId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + databaseid: dataId, + data: data + }; + + if (typeof groupId !== 'undefined') { + params['groupid'] = groupId; + } + + return site.write('mod_data_add_entry', params); + }); + } + + /** + * Approves or unapproves an entry. It does not cache calls. It will fail if offline or cannot connect. + * + * @param {number} entryId Entry ID. + * @param {boolean} approve Whether to approve (true) or unapprove the entry. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the action is done. + */ + approveEntryOnline(entryId: number, approve: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + entryid: entryId, + approve: approve ? 1 : 0 + }; + + return site.write('mod_data_approve_entry', params); + }); + } + + /** + * Deletes an entry. It does not cache calls. It will fail if offline or cannot connect. + * + * @param {number} entryId Entry ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the action is done. + */ + deleteEntryOnline(entryId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + entryid: entryId + }; + + return site.write('mod_data_delete_entry', params); + }); + } + + /** + * Updates an existing entry. It does not cache calls. It will fail if offline or cannot connect. + * + * @param {number} entryId Entry ID. + * @param {any} data The fields data to be updated. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the action is done. + */ + editEntryOnline(entryId: number, data: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + entryid: entryId, + data: data + }; + + return site.write('mod_data_update_entry', params); + }); + } + + /** + * Get cache key for data data WS calls. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getDatabaseDataCacheKey(courseId: number): string { + return this.ROOT_CACHE_KEY + 'data:' + courseId; + } + + /** + * Get prefix cache key for all database activity data WS calls. + * + * @param {number} dataId Data ID. + * @return {string} Cache key. + */ + protected getDatabaseDataPrefixCacheKey(dataId: number): string { + return this.ROOT_CACHE_KEY + dataId; + } + + /** + * Get a database data. If more than one is found, only the first will be returned. + * + * @param {number} courseId Course ID. + * @param {string} key Name of the property to check. + * @param {any} value Value to search. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {boolean} [forceCache=false] True to always get the value from cache, false otherwise. Default false. + * @return {Promise} Promise resolved when the data is retrieved. + */ + protected getDatabaseByKey(courseId: number, key: string, value: any, siteId?: string, forceCache: boolean = false): + Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + courseids: [courseId] + }, + preSets = { + cacheKey: this.getDatabaseDataCacheKey(courseId) + }; + if (forceCache) { + preSets['omitExpires'] = true; + } + + return site.read('mod_data_get_databases_by_courses', params, preSets).then((response) => { + if (response && response.databases) { + const currentData = response.databases.find((data) => data[key] == value); + if (currentData) { + return currentData; + } + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get a data by course module ID. + * + * @param {number} courseId Course ID. + * @param {number} cmId Course module ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {boolean} [forceCache=false] True to always get the value from cache, false otherwise. Default false. + * @return {Promise} Promise resolved when the data is retrieved. + */ + getDatabase(courseId: number, cmId: number, siteId?: string, forceCache: boolean = false): Promise { + return this.getDatabaseByKey(courseId, 'coursemodule', cmId, siteId, forceCache); + } + + /** + * Get a data by ID. + * + * @param {number} courseId Course ID. + * @param {number} id Data ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {boolean} [forceCache=false] True to always get the value from cache, false otherwise. Default false. + * @return {Promise} Promise resolved when the data is retrieved. + */ + getDatabaseById(courseId: number, id: number, siteId?: string, forceCache: boolean = false): Promise { + return this.getDatabaseByKey(courseId, 'id', id, siteId, forceCache); + } + + /** + * Get prefix cache key for all database access information data WS calls. + * + * @param {number} dataId Data ID. + * @return {string} Cache key. + */ + protected getDatabaseAccessInformationDataPrefixCacheKey(dataId: number): string { + return this.getDatabaseDataPrefixCacheKey(dataId) + ':access:'; + } + + /** + * Get cache key for database access information data WS calls. + * + * @param {number} dataId Data ID. + * @param {number} [groupId=0] Group ID. + * @return {string} Cache key. + */ + protected getDatabaseAccessInformationDataCacheKey(dataId: number, groupId: number = 0): string { + return this.getDatabaseAccessInformationDataPrefixCacheKey(dataId) + groupId; + } + + /** + * Get access information for a given database. + * + * @param {number} dataId Data ID. + * @param {number} [groupId] Group ID. + * @param {boolean} [offline=false] True if it should return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache=false] True if it should ignore cached data (it'll always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the database is retrieved. + */ + getDatabaseAccessInformation(dataId: number, groupId?: number, offline: boolean = false, ignoreCache: boolean = false, + siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + databaseid: dataId + }, + preSets = { + cacheKey: this.getDatabaseAccessInformationDataCacheKey(dataId, groupId) + }; + + if (typeof groupId !== 'undefined') { + params['groupid'] = groupId; + } + + if (offline) { + preSets['omitExpires'] = true; + } else if (ignoreCache) { + preSets['getFromCache'] = false; + preSets['emergencyCache'] = false; + } + + return site.read('mod_data_get_data_access_information', params, preSets); + }); + } + + /** + * Get entries for a specific database and group. + * + * @param {number} dataId Data ID. + * @param {number} [groupId=0] Group ID. + * @param {string} [sort=0] Sort the records by this field id, reserved ids are: + * 0: timeadded + * -1: firstname + * -2: lastname + * -3: approved + * -4: timemodified. + * Empty for using the default database setting. + * @param {string} [order=DESC] The direction of the sorting: 'ASC' or 'DESC'. + * Empty for using the default database setting. + * @param {number} [page=0] Page of records to return. + * @param {number} [perPage=PER_PAGE] Records per page to return. Default on PER_PAGE. + * @param {boolean} [forceCache=false] True to always get the value from cache, false otherwise. Default false. + * @param {boolean} [ignoreCache=false] True if it should ignore cached data (it'll always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the database is retrieved. + */ + getEntries(dataId: number, groupId: number = 0, sort: string = '0', order: string = 'DESC', page: number = 0, + perPage: number = AddonModDataProvider.PER_PAGE, forceCache: boolean = false, ignoreCache: boolean = false, + siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + // Always use sort and order params to improve cache usage (entries are identified by params). + const params = { + databaseid: dataId, + returncontents: 1, + page: page, + perpage: perPage, + groupid: groupId, + sort: sort, + order: order + }, + preSets = { + cacheKey: this.getEntriesCacheKey(dataId, groupId) + }; + + if (forceCache) { + preSets['omitExpires'] = true; + } else if (ignoreCache) { + preSets['getFromCache'] = false; + preSets['emergencyCache'] = false; + } + + return site.read('mod_data_get_entries', params, preSets); + }); + } + + /** + * Get cache key for database entries data WS calls. + * + * @param {number} dataId Data ID. + * @param {number} [groupId=0] Group ID. + * @return {string} Cache key. + */ + protected getEntriesCacheKey(dataId: number, groupId: number = 0): string { + return this.getEntriesPrefixCacheKey(dataId) + groupId; + } + + /** + * Get prefix cache key for database all entries data WS calls. + * + * @param {number} dataId Data ID. + * @return {string} Cache key. + */ + protected getEntriesPrefixCacheKey(dataId: number): string { + return this.getDatabaseDataPrefixCacheKey(dataId) + ':entries:'; + } + + /** + * Get an entry of the database activity. + * + * @param {number} dataId Data ID for caching purposes. + * @param {number} entryId Entry ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the database entry is retrieved. + */ + getEntry(dataId: number, entryId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + entryid: entryId, + returncontents: 1 + }, + preSets = { + cacheKey: this.getEntryCacheKey(dataId, entryId) + }; + + return site.read('mod_data_get_entry', params, preSets); + }); + } + + /** + * Get cache key for database entry data WS calls. + * + * @param {number} dataId Data ID for caching purposes. + * @param {number} entryId Entry ID. + * @return {string} Cache key. + */ + protected getEntryCacheKey(dataId: number, entryId: number): string { + return this.getDatabaseDataPrefixCacheKey(dataId) + ':entry:' + entryId; + } + + /** + * Get the list of configured fields for the given database. + * + * @param {number} dataId Data ID. + * @param {boolean} [forceCache=false] True to always get the value from cache, false otherwise. Default false. + * @param {boolean} [ignoreCache=false] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the fields are retrieved. + */ + getFields(dataId: number, forceCache: boolean = false, ignoreCache: boolean = false, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + databaseid: dataId + }, + preSets = { + cacheKey: this.getFieldsCacheKey(dataId) + }; + + if (forceCache) { + preSets['omitExpires'] = true; + } else if (ignoreCache) { + preSets['getFromCache'] = false; + preSets['emergencyCache'] = false; + } + + return site.read('mod_data_get_fields', params, preSets).then((response) => { + if (response && response.fields) { + return response.fields; + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for database fields data WS calls. + * + * @param {number} dataId Data ID. + * @return {string} Cache key. + */ + protected getFieldsCacheKey(dataId: number): string { + return this.getDatabaseDataPrefixCacheKey(dataId) + ':fields'; + } + + /** + * Invalidate the prefetched content. + * To invalidate files, use AddonDataProvider#invalidateFiles. + * + * @param {number} moduleId The module ID. + * @param {number} courseId Course ID of the module. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const promises = []; + + promises.push(this.getDatabase(courseId, moduleId).then((data) => { + const ps = []; + + // Do not invalidate module data before getting module info, we need it! + ps.push(this.invalidateDatabaseData(courseId, siteId)); + ps.push(this.invalidateDatabaseWSData(data.id, siteId)); + + return Promise.all(ps); + })); + + promises.push(this.invalidateFiles(moduleId, siteId)); + + return this.utils.allPromises(promises); + } + + /** + * Invalidates database access information data. + * + * @param {number} dataId Data ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateDatabaseAccessInformationData(dataId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getDatabaseAccessInformationDataPrefixCacheKey(dataId)); + }); + } + + /** + * Invalidates database entries data. + * + * @param {number} dataId Data ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateEntriesData(dataId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getEntriesPrefixCacheKey(dataId)); + }); + } + + /** + * Invalidate the prefetched files. + * + * @param {number} moduleId The module ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the files are invalidated. + */ + invalidateFiles(moduleId: number, siteId?: string): Promise { + return this.filepoolProvider.invalidateFilesByComponent(siteId, AddonModDataProvider.COMPONENT, moduleId); + } + + /** + * Invalidates database data. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateDatabaseData(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getDatabaseDataCacheKey(courseId)); + }); + } + + /** + * Invalidates database data except files and module info. + * + * @param {number} databaseId Data ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateDatabaseWSData(databaseId: number, siteId: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getDatabaseDataPrefixCacheKey(databaseId)); + }); + } + + /** + * Return whether or not the plugin is enabled in a certain site. Plugin is enabled if the database WS are available. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. + * @since 3.3 + */ + isPluginEnabled(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.wsAvailable('mod_data_get_data_access_information'); + }); + } + + /** + * Report the database as being viewed. + * + * @param {number} id Module ID. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logView(id: number): Promise { + const params = { + databaseid: id + }; + + return this.sitesProvider.getCurrentSite().write('mod_data_view_database', params); + } + + /** + * Performs search over a database. + * + * @param {number} dataId The data instance id. + * @param {number} [groupId=0] Group id, 0 means that the function will determine the user group. + * @param {string} [search] Search text. It will be used if advSearch is not defined. + * @param {any} [advSearch] Advanced search data. + * @param {string} [sort] Sort by this field. + * @param {string} [order] The direction of the sorting. + * @param {number} [page=0] Page of records to return. + * @param {number} [perPage=PER_PAGE] Records per page to return. Default on AddonModDataProvider.PER_PAGE. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the action is done. + */ + searchEntries(dataId: number, groupId: number = 0, search?: string, advSearch?: any, sort?: string, order?: string, + page: number = 0, perPage: number = AddonModDataProvider.PER_PAGE, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + databaseid: dataId, + groupid: groupId, + returncontents: 1, + page: page, + perpage: perPage + }, + preSets = { + getFromCache: false, + saveToCache: true, + emergencyCache: true + }; + + if (typeof sort != 'undefined') { + params['sort'] = sort; + } + + if (typeof order !== 'undefined') { + params['order'] = order; + } + + if (typeof search !== 'undefined') { + params['search'] = search; + } + + if (typeof advSearch !== 'undefined') { + params['advsearch'] = advSearch; + } + + return site.read('mod_data_search_entries', params, preSets); + }); + } +} diff --git a/src/addon/mod/data/providers/default-field-handler.ts b/src/addon/mod/data/providers/default-field-handler.ts new file mode 100644 index 000000000..2160d8a9e --- /dev/null +++ b/src/addon/mod/data/providers/default-field-handler.ts @@ -0,0 +1,100 @@ +// (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 { AddonModDataFieldHandler } from './fields-delegate'; + +/** + * Default handler used when a field plugin doesn't have a specific implementation. + */ +@Injectable() +export class AddonModDataDefaultFieldHandler implements AddonModDataFieldHandler { + name = 'AddonModDataDefaultFieldHandler'; + type = 'default'; + + /** + * Get field search data in the input data. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the search form. + * @return {any} With name and value of the data to be sent. + */ + getFieldSearchData(field: any, inputData: any): any { + return false; + } + + /** + * Get field edit data in the input data. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the edit form. + * @return {any} With name and value of the data to be sent. + */ + getFieldEditData(field: any, inputData: any, originalFieldData: any): any { + return false; + } + + /** + * Get field data in changed. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the edit form. + * @param {any} originalFieldData Original field entered data. + * @return {Promise | boolean} If the field has changes. + */ + hasFieldDataChanged(field: any, inputData: any, originalFieldData: any): Promise | boolean { + return false; + } + + /** + * Get field edit files in the input data. + * + * @param {any} field Defines the field.. + * @return {any} With name and value of the data to be sent. + */ + getFieldEditFiles(field: any, inputData: any, originalFieldData: any): any { + return []; + } + + /** + * Check and get field requeriments. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the edit form. + * @return {string | false} String with the notification or false. + */ + getFieldsNotifications(field: any, inputData: any): string | false { + return false; + } + + /** + * Override field content data with offline submission. + * + * @param {any} originalContent Original data to be overriden. + * @param {any} offlineContent Array with all the offline data to override. + * @param {any} [offlineFiles] Array with all the offline files in the field. + * @return {any} Data overriden + */ + overrideData(originalContent: any, offlineContent: any, offlineFiles?: any): any { + return originalContent; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } +} diff --git a/src/addon/mod/data/providers/fields-delegate.ts b/src/addon/mod/data/providers/fields-delegate.ts new file mode 100644 index 000000000..a72f0ad46 --- /dev/null +++ b/src/addon/mod/data/providers/fields-delegate.ts @@ -0,0 +1,226 @@ +// (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 { Injector, Injectable } from '@angular/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { AddonModDataDefaultFieldHandler } from './default-field-handler'; + +/** + * Interface that all fields handlers must implement. + */ +export interface AddonModDataFieldHandler extends CoreDelegateHandler { + + /** + * Name of the type of data field the handler supports. E.g. 'checkbox'. + * @type {string} + */ + type: string; + + /** + * Return the Component to use to display the plugin data. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} field The field object. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent?(injector: Injector, plugin: any): any | Promise; + + /** + * Get field search data in the input data. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the search form. + * @return {any} With name and value of the data to be sent. + */ + getFieldSearchData?(field: any, inputData: any): any; + + /** + * Get field edit data in the input data. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the edit form. + * @return {any} With name and value of the data to be sent. + */ + getFieldEditData?(field: any, inputData: any, originalFieldData: any): any; + + /** + * Get field data in changed. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the edit form. + * @param {any} originalFieldData Original field entered data. + * @return {Promise | boolean} If the field has changes. + */ + hasFieldDataChanged?(field: any, inputData: any, originalFieldData: any): Promise | boolean; + + /** + * Get field edit files in the input data. + * + * @param {any} field Defines the field.. + * @return {any} With name and value of the data to be sent. + */ + getFieldEditFiles?(field: any, inputData: any, originalFieldData: any): any; + + /** + * Check and get field requeriments. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the edit form. + * @return {string | false} String with the notification or false. + */ + getFieldsNotifications?(field: any, inputData: any): string | false; + + /** + * Override field content data with offline submission. + * + * @param {any} originalContent Original data to be overriden. + * @param {any} offlineContent Array with all the offline data to override. + * @param {any} [offlineFiles] Array with all the offline files in the field. + * @return {any} Data overriden + */ + overrideData?(originalContent: any, offlineContent: any, offlineFiles?: any): any; +} + +/** + * Delegate to register database fields handlers. + */ +@Injectable() +export class AddonModDataFieldsDelegate extends CoreDelegate { + + protected handlerNameProperty = 'type'; + + constructor(logger: CoreLoggerProvider, sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, + protected utils: CoreUtilsProvider, protected defaultHandler: AddonModDataDefaultFieldHandler) { + super('AddonModDataFieldsDelegate', logger, sitesProvider, eventsProvider); + } + + /** + * Get the component to use for a certain field field. + * + * @param {Injector} injector Injector. + * @param {any} field The field object. + * @return {Promise} Promise resolved with the component to use, undefined if not found. + */ + getComponentForField(injector: Injector, field: any): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(field.type, 'getComponent', [injector, field])); + } + + /** + * Get database data in the input data to search. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the search form. + * @return {any} Name and data field. + */ + getFieldSearchData(field: any, inputData: any): any { + return this.executeFunctionOnEnabled(field.type, 'getFieldSearchData', [field, inputData]); + } + + /** + * Get database data in the input data to add or update entry. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the search form. + * @param {any} originalFieldData Original field entered data. + * @return {any} Name and data field. + */ + getFieldEditData(field: any, inputData: any, originalFieldData: any): any { + return this.executeFunctionOnEnabled(field.type, 'getFieldEditData', [field, inputData, originalFieldData]); + } + + /** + * Get database data in the input files to add or update entry. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the search form. + * @param {any} originalFieldData Original field entered data. + * @return {any} Name and data field. + */ + getFieldEditFiles(field: any, inputData: any, originalFieldData: any): any { + return this.executeFunctionOnEnabled(field.type, 'getFieldEditFiles', [field, inputData, originalFieldData]); + } + + /** + * Check and get field requeriments. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the edit form. + * @return {string} String with the notification or false. + */ + getFieldsNotifications(field: any, inputData: any): string { + return this.executeFunctionOnEnabled(field.type, 'getFieldsNotifications', [field, inputData]); + } + + /** + * Check if field type manage files or not. + * + * @param {any} field Defines the field to be checked. + * @return {boolean} If the field type manages files. + */ + hasFiles(field: any): boolean { + return this.hasFunction(field.type, 'getFieldEditFiles'); + } + + /** + * Check if the data has changed for a certain field. + * + * @param {any} field Defines the field to be rendered. + * @param {any} inputData Data entered in the search form. + * @param {any} originalFieldData Original field entered data. + * @return {Promise} Promise rejected if has changed, resolved if no changes. + */ + hasFieldDataChanged(field: any, inputData: any, originalFieldData: any): Promise { + if (!this.hasFunction(field.type, 'hasFieldDataChanged')) { + return Promise.resolve(); + } + + return Promise.resolve(this.executeFunctionOnEnabled(field.type, 'hasFieldDataChanged', + [field, inputData, originalFieldData])).then((result) => { + return result ? Promise.reject(null) : Promise.resolve(); + }); + } + + /** + * Check if a field plugin is supported. + * + * @param {string} pluginType Type of the plugin. + * @return {boolean} True if supported, false otherwise. + */ + isPluginSupported(pluginType: string): boolean { + return this.hasHandler(pluginType, true); + } + + /** + * Override field content data with offline submission. + * + * @param {any} field Defines the field to be rendered. + * @param {any} originalContent Original data to be overriden. + * @param {any} offlineContent Array with all the offline data to override. + * @param {any} [offlineFiles] Array with all the offline files in the field. + * @return {any} Data overriden + */ + overrideData(field: any, originalContent: any, offlineContent: any, offlineFiles?: any): any { + if (!offlineContent || !this.hasFunction(field.type, 'overrideData')) { + return originalContent; + } + + return this.executeFunctionOnEnabled(field.type, 'overrideData', [originalContent || {}, offlineContent, offlineFiles]); + } + +} diff --git a/src/addon/mod/data/providers/helper.ts b/src/addon/mod/data/providers/helper.ts new file mode 100644 index 000000000..43ffe33fe --- /dev/null +++ b/src/addon/mod/data/providers/helper.ts @@ -0,0 +1,348 @@ +// (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 } from '@providers/sites'; +import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { TranslateService } from '@ngx-translate/core'; +import { AddonModDataFieldsDelegate } from './fields-delegate'; +import { AddonModDataOfflineProvider } from './offline'; +import { AddonModDataProvider } from './data'; + +/** + * Service that provides helper functions for datas. + */ +@Injectable() +export class AddonModDataHelperProvider { + + constructor(private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, + private translate: TranslateService, private fieldsDelegate: AddonModDataFieldsDelegate, + private dataOffline: AddonModDataOfflineProvider, private fileUploaderProvider: CoreFileUploaderProvider) { } + + /** + * Returns the record with the offline actions applied. + * + * @param {any} record Entry to modify. + * @param {any} offlineActions Offline data with the actions done. + * @param {any} fields Entry defined fields indexed by fieldid. + * @return {any} Modified entry. + */ + applyOfflineActions(record: any, offlineActions: any[], fields: any[]): any { + const promises = []; + + offlineActions.forEach((action) => { + switch (action.action) { + case 'approve': + record.approved = true; + break; + case 'disapprove': + record.approved = false; + break; + case 'delete': + record.deleted = true; + break; + case 'add': + case 'edit': + const offlineContents = {}; + + action.fields.forEach((offlineContent) => { + if (typeof offlineContents[offlineContent.fieldid] == 'undefined') { + offlineContents[offlineContent.fieldid] = {}; + } + + if (offlineContent.subfield) { + offlineContents[offlineContent.fieldid][offlineContent.subfield] = JSON.parse(offlineContent.value); + } else { + offlineContents[offlineContent.fieldid][''] = JSON.parse(offlineContent.value); + } + }); + + // Override field contents. + fields.forEach((field) => { + if (this.fieldsDelegate.hasFiles(field)) { + promises.push(this.getStoredFiles(record.dataid, record.id, field.id).then((offlineFiles) => { + record.contents[field.id] = this.fieldsDelegate.overrideData(field, record.contents[field.id], + offlineContents[field.id], offlineFiles); + })); + } else { + record.contents[field.id] = this.fieldsDelegate.overrideData(field, record.contents[field.id], + offlineContents[field.id]); + } + }); + break; + default: + break; + } + }); + + return Promise.all(promises).then(() => { + return record; + }); + } + + /** + * Displays Advanced Search Fields. + * + * @param {string} template Template HMTL. + * @param {any[]} fields Fields that defines every content in the entry. + * @return {string} Generated HTML. + */ + displayAdvancedSearchFields(template: string, fields: any[]): string { + if (!template) { + return ''; + } + + let replace; + + // Replace the fields found on template. + fields.forEach((field) => { + replace = '[[' + field.name + ']]'; + replace = replace.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); + replace = new RegExp(replace, 'gi'); + + // Replace field by a generic directive. + const render = ''; + template = template.replace(replace, render); + }); + + // Not pluginable other search elements. + // Replace firstname field by the text input. + replace = new RegExp('##fn##', 'gi'); + let render = ''; + template = template.replace(replace, render); + + // Replace lastname field by the text input. + replace = new RegExp('##ln##', 'gi'); + render = ''; + template = template.replace(replace, render); + + return template; + } + + /** + * Displays fields for being shown. + * + * @param {string} template Template HMTL. + * @param {any[]} fields Fields that defines every content in the entry. + * @param {any} entry Entry. + * @param {string} mode Mode list or show. + * @param {any} actions Actions that can be performed to the record. + * @return {string} Generated HTML. + */ + displayShowFields(template: string, fields: any[], entry: any, mode: string, actions: any): string { + if (!template) { + return ''; + } + + let replace, render; + + // Replace the fields found on template. + fields.forEach((field) => { + replace = '[[' + field.name + ']]'; + replace = replace.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); + replace = new RegExp(replace, 'gi'); + + // Replace field by a generic directive. + render = ''; + template = template.replace(replace, render); + }); + + for (const action in actions) { + replace = new RegExp('##' + action + '##', 'gi'); + // Is enabled? + if (actions[action]) { + if (action == 'moreurl') { + // Render more url directly because it can be part of an HTML attribute. + render = this.sitesProvider.getCurrentSite().getURL() + '/mod/data/view.php?d={{data.id}}&rid=' + entry.id; + } else if (action == 'approvalstatus') { + render = this.translate.instant('addon.mod_data.' + (entry.approved ? 'approved' : 'notapproved')); + } else { + render = ''; + } + template = template.replace(replace, render); + } else { + template = template.replace(replace, ''); + } + } + + return template; + } + + /** + * Returns an object with all the actions that the user can do over the record. + * + * @param {any} database Database activity. + * @param {any} accessInfo Access info to the activity. + * @param {any} record Entry or record where the actions will be performed. + * @return {any} Keyed with the action names and boolean to evalute if it can or cannot be done. + */ + getActions(database: any, accessInfo: any, record: any): any { + return { + more: true, + moreurl: true, + user: true, + userpicture: true, + timeadded: true, + timemodified: true, + + edit: record.canmanageentry && !record.deleted, // This already checks capabilities and readonly period. + delete: record.canmanageentry, + approve: database.approval && accessInfo.canapprove && !record.approved && !record.deleted, + disapprove: database.approval && accessInfo.canapprove && record.approved && !record.deleted, + + approvalstatus: database.approval, + comments: database.comments, + + // Unsupported actions. + delcheck: false, + export: false + }; + } + + /** + * Retrieve the entered data in search in a form. + * We don't use ng-model because it doesn't detect changes done by JavaScript. + * + * @param {any} form Form (DOM element). + * @param {any[]} fields Fields that defines every content in the entry. + * @return {any[]} Array with the answers. + */ + getSearchDataFromForm(form: any, fields: any[]): any[] { + if (!form || !form.elements) { + return []; + } + + const searchedData = this.domUtils.getDataFromForm(form); + + // Filter and translate fields to each field plugin. + const advancedSearch = []; + fields.forEach((field) => { + const fieldData = this.fieldsDelegate.getFieldSearchData(field, searchedData); + + if (fieldData) { + fieldData.forEach((data) => { + data.value = JSON.stringify(data.value); + // WS wants values in Json format. + advancedSearch.push(data); + }); + } + }); + + // Not pluginable other search elements. + if (searchedData['firstname']) { + // WS wants values in Json format. + advancedSearch.push({ + name: 'firstname', + value: JSON.stringify(searchedData['firstname']) + }); + } + + if (searchedData['lastname']) { + // WS wants values in Json format. + advancedSearch.push({ + name: 'lastname', + value: JSON.stringify(searchedData['lastname']) + }); + } + + return advancedSearch; + } + + /** + * Get a list of stored attachment files for a new entry. See $mmaModDataHelper#storeFiles. + * + * @param {number} dataId Database ID. + * @param {number} entryId Entry ID or, if creating, timemodified. + * @param {number} fieldId Field ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the files. + */ + getStoredFiles(dataId: number, entryId: number, fieldId: number, siteId?: string): Promise { + return this.dataOffline.getEntryFieldFolder(dataId, entryId, fieldId, siteId).then((folderPath) => { + return this.fileUploaderProvider.getStoredFiles(folderPath).catch(() => { + // Ignore not found files. + return []; + }); + }); + } + + /** + * Add a prefix to all rules in a CSS string. + * + * @param {string} css CSS code to be prefixed. + * @param {string} prefix Prefix css selector. + * @return {string} Prefixed CSS. + */ + prefixCSS(css: string, prefix: string): string { + if (!css) { + return ''; + } + + // Remove comments first. + let regExp = /\/\*[\s\S]*?\*\/|([^:]|^)\/\/.*$/gm; + css = css.replace(regExp, ''); + // Add prefix. + regExp = /([^]*?)({[^]*?}|,)/g; + + return css.replace(regExp, prefix + ' $1 $2'); + } + + /** + * Given a list of files (either online files or local files), store the local files in a local folder + * to be submitted later. + * + * @param {number} dataId Database ID. + * @param {number} entryId Entry ID or, if creating, timemodified. + * @param {number} fieldId Field ID. + * @param {any[]} files List of files. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success, rejected otherwise. + */ + storeFiles(dataId: number, entryId: number, fieldId: number, files: any[], siteId?: string): Promise { + // Get the folder where to store the files. + return this.dataOffline.getEntryFieldFolder(dataId, entryId, fieldId, siteId).then((folderPath) => { + return this.fileUploaderProvider.storeFilesToUpload(folderPath, files); + }); + } + + /** + * Upload or store some files, depending if the user is offline or not. + * + * @param {number} dataId Database ID. + * @param {number} [itemId=0] Draft ID to use. Undefined or 0 to create a new draft ID. + * @param {number} entryId Entry ID or, if creating, timemodified. + * @param {number} fieldId Field ID. + * @param {any[]} files List of files. + * @param {boolean} offline True if files sould be stored for offline, false to upload them. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success. + */ + uploadOrStoreFiles(dataId: number, itemId: number = 0, entryId: number, fieldId: number, files: any[], offline: boolean, + siteId?: string): Promise { + if (files.length) { + if (offline) { + return this.storeFiles(dataId, entryId, fieldId, files, siteId); + } + + return this.fileUploaderProvider.uploadOrReuploadFiles(files, AddonModDataProvider.COMPONENT, itemId, siteId); + } + + return Promise.resolve(0); + } +} diff --git a/src/addon/mod/data/providers/link-handler.ts b/src/addon/mod/data/providers/link-handler.ts new file mode 100644 index 000000000..4ba21ea6c --- /dev/null +++ b/src/addon/mod/data/providers/link-handler.ts @@ -0,0 +1,29 @@ +// (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 { CoreContentLinksModuleIndexHandler } from '@core/contentlinks/classes/module-index-handler'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; + +/** + * Handler to treat links to data. + */ +@Injectable() +export class AddonModDataLinkHandler extends CoreContentLinksModuleIndexHandler { + name = 'AddonModDataLinkHandler'; + + constructor(courseHelper: CoreCourseHelperProvider) { + super(courseHelper, AddonModDataLinkHandler.name, 'data'); + } +} diff --git a/src/addon/mod/data/providers/module-handler.ts b/src/addon/mod/data/providers/module-handler.ts new file mode 100644 index 000000000..b741ec975 --- /dev/null +++ b/src/addon/mod/data/providers/module-handler.ts @@ -0,0 +1,72 @@ +// (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 { NavController, NavOptions } from 'ionic-angular'; +import { AddonModDataIndexComponent } from '../components/index/index'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { AddonModDataProvider } from './data'; + +/** + * Handler to support data modules. + */ +@Injectable() +export class AddonModDataModuleHandler implements CoreCourseModuleHandler { + name = 'AddonModData'; + modName = 'data'; + + constructor(private courseProvider: CoreCourseProvider, private dataProvider: AddonModDataProvider) { } + + /** + * Check if the handler is enabled on a site level. + * + * @return {Promise} Whether or not the handler is enabled on a site level. + */ + isEnabled(): Promise { + return this.dataProvider.isPluginEnabled(); + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param {any} module The module object. + * @param {number} courseId The course ID. + * @param {number} sectionId The section ID. + * @return {CoreCourseModuleHandlerData} Data to render the module. + */ + getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData { + return { + icon: this.courseProvider.getModuleIconSrc('data'), + title: module.name, + class: 'addon-mod_data-handler', + showDownloadButton: true, + action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { + navCtrl.push('AddonModDataIndexPage', {module: module, courseId: courseId}, options); + } + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * + * @param {any} course The course object. + * @param {any} module The module object. + * @return {any} The component to use, undefined if not found. + */ + getMainComponent(course: any, module: any): any { + return AddonModDataIndexComponent; + } +} diff --git a/src/addon/mod/data/providers/offline.ts b/src/addon/mod/data/providers/offline.ts new file mode 100644 index 000000000..a4f01382b --- /dev/null +++ b/src/addon/mod/data/providers/offline.ts @@ -0,0 +1,195 @@ +// (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 { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreFileProvider } from '@providers/file'; + +/** + * Service to handle Offline data. + */ +@Injectable() +export class AddonModDataOfflineProvider { + + protected logger; + + // Variables for database. + protected SURVEY_TABLE = 'addon_mod_data_entry'; + protected tablesSchema = [ + { + name: this.SURVEY_TABLE, + columns: [ + { + name: 'dataid', + type: 'INTEGER' + }, + { + name: 'courseid', + type: 'INTEGER' + }, + { + name: 'groupid', + type: 'INTEGER' + }, + { + name: 'action', + type: 'TEXT' + }, + { + name: 'entryid', + type: 'INTEGER' + }, + { + name: 'fields', + type: 'TEXT' + }, + { + name: 'timemodified', + type: 'INTEGER' + } + ], + primaryKeys: ['dataid', 'entryid'] + } + ]; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, + private fileProvider: CoreFileProvider) { + this.logger = logger.getInstance('AddonModDataOfflineProvider'); + this.sitesProvider.createTablesFromSchema(this.tablesSchema); + } + + /** + * Delete all the actions of an entry. + * + * @param {number} dataId Database ID. + * @param {number} entryId Database entry ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + deleteAllEntryActions(dataId: number, entryId: number, siteId?: string): Promise { + return this.getEntryActions(dataId, entryId, siteId).then((actions) => { + const promises = []; + + actions.forEach((action) => { + promises.push(this.deleteEntry(dataId, entryId, action.action, siteId)); + }); + + return Promise.all(promises); + }); + } + + /** + * Delete an stored entry. + * + * @param {number} dataId Database ID. + * @param {number} entryId Database entry Id. + * @param {string} action Action to be done + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + deleteEntry(dataId: number, entryId: number, action: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(this.SURVEY_TABLE, {dataid: dataId, entryid: entryId, action: action}); + }); + } + + /** + * Get all the stored entry data from all the databases. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with entries. + */ + getAllEntries(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getAllRecords(this.SURVEY_TABLE); + }); + } + + /** + * Get all the stored entry data from a certain database. + * + * @param {number} dataId Database ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with entries. + */ + getDatabaseEntries(dataId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.SURVEY_TABLE, {dataid: dataId}); + }); + } + + /** + * Get an all stored entry actions data. + * + * @param {number} dataId Database ID. + * @param {number} entryId Database entry Id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with entry actions. + */ + getEntryActions(dataId: number, entryId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.SURVEY_TABLE, {dataid: dataId, entryid: entryId}); + }); + } + + /** + * Check if there are offline entries to send. + * + * @param {number} dataId Database ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: true if has offline answers, false otherwise. + */ + hasOfflineData(dataId: number, siteId?: string): Promise { + return this.getDatabaseEntries(dataId, siteId).then((entries) => { + return !!entries.length; + }).catch(() => { + // No offline data found, return false. + return false; + }); + } + + /** + * Get the path to the folder where to store files for offline files in a database. + * + * @param {number} dataId Database ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the path. + */ + protected getDatabaseFolder(dataId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + + const siteFolderPath = this.fileProvider.getSiteFolder(site.getId()), + folderPath = 'offlinedatabase/' + dataId; + + return this.textUtils.concatenatePaths(siteFolderPath, folderPath); + }); + } + + /** + * Get the path to the folder where to store files for a new offline entry. + * + * @param {number} dataId Database ID. + * @param {number} entryId The ID of the entry. + * @param {number} fieldId Field ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the path. + */ + getEntryFieldFolder(dataId: number, entryId: number, fieldId: number, siteId?: string): Promise { + return this.getDatabaseFolder(dataId, siteId).then((folderPath) => { + return this.textUtils.concatenatePaths(folderPath, entryId + '_' + fieldId); + }); + } +} diff --git a/src/addon/mod/data/providers/prefetch-handler.ts b/src/addon/mod/data/providers/prefetch-handler.ts new file mode 100644 index 000000000..6d575e031 --- /dev/null +++ b/src/addon/mod/data/providers/prefetch-handler.ts @@ -0,0 +1,102 @@ +// (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, Injector } from '@angular/core'; +import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler'; +import { AddonModDataProvider } from './data'; +import { AddonModDataHelperProvider } from './helper'; +import { CoreFilepoolProvider } from '@providers/filepool'; + +/** + * Handler to prefetch databases. + */ +@Injectable() +export class AddonModDataPrefetchHandler extends CoreCourseModulePrefetchHandlerBase { + name = 'data'; + component = AddonModDataProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^entries$|^gradeitems$|^outcomes$|^comments$|^ratings/; + + constructor(injector: Injector, protected dataProvider: AddonModDataProvider, + protected filepoolProvider: CoreFilepoolProvider, protected dataHelper: AddonModDataHelperProvider) { + super(injector); + } + + /** + * Download or prefetch the content. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @param {boolean} [prefetch] True to prefetch, false to download right away. + * @param {string} [dirPath] Path of the directory where to store all the content files. This is to keep the files + * relative paths and make the package work in an iframe. Undefined to download the files + * in the filepool root data. + * @return {Promise} Promise resolved when all content is downloaded. Data returned is not reliable. + */ + downloadOrPrefetch(module: any, courseId: number, prefetch?: boolean, dirPath?: string): Promise { + const promises = []; + + promises.push(super.downloadOrPrefetch(module, courseId, prefetch)); + promises.push(this.dataProvider.getDatabase(courseId, module.id).then((data) => { + // @TODO + })); + + return Promise.all(promises); + } + + /** + * Returns data intro files. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved with list of intro files. + */ + getIntroFiles(module: any, courseId: number): Promise { + return this.dataProvider.getDatabase(courseId, module.id).catch(() => { + // Not found, return undefined so module description is used. + }).then((data) => { + return this.getIntroFilesFromInstance(module, data); + }); + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return this.dataProvider.invalidateContent(moduleId, courseId); + } + + /** + * Invalidate WS calls needed to determine module status. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when invalidated. + */ + invalidateModule(module: any, courseId: number): Promise { + return this.dataProvider.invalidateDatabaseData(courseId); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + isEnabled(): boolean | Promise { + return this.dataProvider.isPluginEnabled(); + } +} diff --git a/src/addon/mod/data/providers/sync-cron-handler.ts b/src/addon/mod/data/providers/sync-cron-handler.ts new file mode 100644 index 000000000..2560e571b --- /dev/null +++ b/src/addon/mod/data/providers/sync-cron-handler.ts @@ -0,0 +1,47 @@ +// (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 { AddonModDataSyncProvider } from './sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonModDataSyncCronHandler implements CoreCronHandler { + name = 'AddonModDataSyncCronHandler'; + + constructor(private dataSync: AddonModDataSyncProvider) {} + + /** + * 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. + * @return {Promise} Promise resolved when done, rejected if failure. + */ + execute(siteId?: string): Promise { + return this.dataSync.syncAllDatabases(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return 600000; // 10 minutes. + } +} diff --git a/src/addon/mod/data/providers/sync.ts b/src/addon/mod/data/providers/sync.ts new file mode 100644 index 000000000..3a7ccfb0f --- /dev/null +++ b/src/addon/mod/data/providers/sync.ts @@ -0,0 +1,337 @@ +// (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 { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreAppProvider } from '@providers/app'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { AddonModDataOfflineProvider } from './offline'; +import { AddonModDataProvider } from './data'; +import { AddonModDataHelperProvider } from './helper'; +import { CoreEventsProvider } from '@providers/events'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreSyncProvider } from '@providers/sync'; + +/** + * Service to sync databases. + */ +@Injectable() +export class AddonModDataSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'addon_mod_data_autom_synced'; + protected componentTranslate: string; + + constructor(protected sitesProvider: CoreSitesProvider, protected loggerProvider: CoreLoggerProvider, + protected appProvider: CoreAppProvider, private dataOffline: AddonModDataOfflineProvider, + private eventsProvider: CoreEventsProvider, private dataProvider: AddonModDataProvider, + protected translate: TranslateService, private utils: CoreUtilsProvider, courseProvider: CoreCourseProvider, + syncProvider: CoreSyncProvider, protected textUtils: CoreTextUtilsProvider, + private dataHelper: AddonModDataHelperProvider) { + super('AddonModDataSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); + this.componentTranslate = courseProvider.translateModuleName('data'); + } + + /** + * Check if a database has data to synchronize. + * + * @param {number} dataId Database ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: true if has data to sync, false otherwise. + */ + hasDataToSync(dataId: number, siteId?: string): Promise { + return this.dataOffline.hasOfflineData(dataId, siteId); + } + + /** + * Try to synchronize all the databases in a certain site or in all sites. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllDatabases(siteId?: string): Promise { + return this.syncOnSites('all databases', this.syncAllDatabasesFunc.bind(this), undefined, siteId); + } + + /** + * Sync all pending databases on a site. + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + protected syncAllDatabasesFunc(siteId?: string): Promise { + // Get all data answers pending to be sent in the site. + return this.dataOffline.getAllEntries(siteId).then((offlineActions) => { + const promises = {}; + + // Do not sync same database twice. + offlineActions.forEach((action) => { + if (typeof promises[action.dataid] != 'undefined') { + return; + } + + promises[action.dataid] = this.syncDatabaseIfNeeded(action.dataid, siteId) + .then((result) => { + if (result && result.updated) { + // Sync done. Send event. + this.eventsProvider.trigger(AddonModDataSyncProvider.AUTO_SYNCED, { + dataId: action.dataid, + warnings: result.warnings + }, siteId); + } + }); + }); + + // Promises will be an object so, convert to an array first; + return Promise.all(this.utils.objectToArray(promises)); + }); + } + + /** + * Sync a database only if a certain time has passed since the last time. + * + * @param {number} dataId Database ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is synced or if it doesn't need to be synced. + */ + syncDatabaseIfNeeded(dataId: number, siteId?: string): Promise { + return this.isSyncNeeded(dataId, siteId).then((needed) => { + if (needed) { + return this.syncDatabase(dataId, siteId); + } + }); + } + + /** + * Synchronize a data. + * + * @param {number} dataId Data ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + syncDatabase(dataId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (this.isSyncing(dataId, siteId)) { + // There's already a sync ongoing for this data and user, return the promise. + return this.getOngoingSync(dataId, siteId); + } + + // Verify that data isn't blocked. + if (this.syncProvider.isBlocked(AddonModDataProvider.COMPONENT, dataId, siteId)) { + this.logger.debug(`Cannot sync database '${dataId}' because it is blocked.`); + + return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); + } + + this.logger.debug(`Try to sync data '${dataId}' in site ${siteId}'`); + + let courseId, + data; + const result = { + warnings: [], + updated: false + }; + + // Get answers to be sent. + const syncPromise = this.dataOffline.getDatabaseEntries(dataId, siteId).catch(() => { + // No offline data found, return empty object. + return []; + }).then((offlineActions) => { + if (!offlineActions.length) { + // Nothing to sync. + return; + } + + if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + courseId = offlineActions[0].courseid; + + // Send the answers. + return this.dataProvider.getDatabaseById(courseId, dataId, siteId).then((database) => { + data = database; + + const offlineEntries = {}; + + offlineActions.forEach((entry) => { + if (typeof offlineEntries[entry.entryid] == 'undefined') { + offlineEntries[entry.entryid] = []; + } + offlineEntries[entry.entryid].push(entry); + }); + + const promises = this.utils.objectToArray(offlineEntries).map((entryActions) => { + return this.syncEntry(data, entryActions, result, siteId); + }); + + return Promise.all(promises); + }).then(() => { + if (result.updated) { + // Data has been sent to server. Now invalidate the WS calls. + return this.dataProvider.invalidateContent(data.cmid, courseId, siteId).catch(() => { + // Ignore errors. + }); + } + }); + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(dataId, siteId); + }).then(() => { + return result; + }); + + return this.addOngoingSync(dataId, syncPromise, siteId); + } + + /** + * Synchronize an entry. + * + * @param {any} data Database. + * @param {any} entryActions Entry actions. + * @param {any} result Object with the result of the sync. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success, rejected otherwise. + */ + protected syncEntry(data: any, entryActions: any[], result: any, siteId?: string): Promise { + let discardError, + timePromise, + entryId = 0, + offlineId, + deleted = false; + + const promises = []; + + // Sort entries by timemodified. + entryActions = entryActions.sort((a: any, b: any) => a.timemodified - b.timemodified); + + entryId = entryActions[0].entryid; + + if (entryId > 0) { + timePromise = this.dataProvider.getEntry(data.id, entryId, siteId).then((entry) => { + return entry.entry.timemodified; + }).catch(() => { + return -1; + }); + } else { + offlineId = entryId; + timePromise = Promise.resolve(0); + } + + return timePromise.then((timemodified) => { + if (timemodified < 0 || timemodified >= entryActions[0].timemodified) { + // The entry was not found in Moodle or the entry has been modified, discard the action. + result.updated = true; + discardError = this.translate.instant('addon.mod_data.warningsubmissionmodified'); + + return this.dataOffline.deleteAllEntryActions(data.id, entryId, siteId); + } + + entryActions.forEach((action) => { + let actionPromise; + const proms = []; + + entryId = action.entryid > 0 ? action.entryid : entryId; + + if (action.fields) { + action.fields.forEach((field) => { + // Upload Files if asked. + const value = JSON.parse(field.value); + if (value.online || value.offline) { + let files = value.online || []; + const fileProm = value.offline ? this.dataHelper.getStoredFiles(action.dataid, entryId, field.fieldid) : + Promise.resolve([]); + + proms.push(fileProm.then((offlineFiles) => { + files = files.concat(offlineFiles); + + return this.dataHelper.uploadOrStoreFiles(action.dataid, 0, entryId, field.fieldid, files, false, + siteId).then((filesResult) => { + field.value = JSON.stringify(filesResult); + }); + })); + } + }); + } + + actionPromise = Promise.all(proms).then(() => { + // Perform the action. + switch (action.action) { + case 'add': + return this.dataProvider.addEntryOnline(action.dataid, action.fields, data.groupid, siteId) + .then((result) => { + entryId = result.newentryid; + }); + case 'edit': + return this.dataProvider.editEntryOnline(entryId, action.fields, siteId); + case 'approve': + return this.dataProvider.approveEntryOnline(entryId, true, siteId); + case 'disapprove': + return this.dataProvider.approveEntryOnline(entryId, false, siteId); + case 'delete': + return this.dataProvider.deleteEntryOnline(entryId, siteId).then(() => { + deleted = true; + }); + default: + break; + } + }); + + promises.push(actionPromise.catch((error) => { + if (error && error.wserror) { + // The WebService has thrown an error, this means it cannot be performed. Discard. + discardError = error.error; + } else { + // Couldn't connect to server, reject. + return Promise.reject(error && error.error); + } + }).then(() => { + // Delete the offline data. + result.updated = true; + + return this.dataOffline.deleteEntry(action.dataid, action.entryid, action.action, siteId); + })); + }); + + return Promise.all(promises); + }).then(() => { + if (discardError) { + // Submission was discarded, add a warning. + const message = this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: data.name, + error: discardError + }); + + if (result.warnings.indexOf(message) == -1) { + result.warnings.push(message); + } + } + + // Sync done. Send event. + this.eventsProvider.trigger(AddonModDataSyncProvider.AUTO_SYNCED, { + dataId: data.id, + entryId: entryId, + offlineEntryId: offlineId, + warnings: result.warnings, + deleted: deleted + }, siteId); + }); + } + +} diff --git a/src/addon/mod/feedback/providers/sync.ts b/src/addon/mod/feedback/providers/sync.ts index 7653014b8..469090738 100644 --- a/src/addon/mod/feedback/providers/sync.ts +++ b/src/addon/mod/feedback/providers/sync.ts @@ -139,7 +139,7 @@ export class AddonModFeedbackSyncProvider extends CoreSyncBaseProvider { let courseId, feedback; - this.logger.debug(`Try to sync feedback '${feedbackId}'`); + this.logger.debug(`Try to sync feedback '${feedbackId}' in site ${siteId}'`); // Get offline responses to be sent. const syncPromise = this.feedbackOffline.getFeedbackResponses(feedbackId, siteId).catch(() => { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 0d21aa600..e690cd1ba 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -81,6 +81,7 @@ import { AddonModAssignModule } from '@addon/mod/assign/assign.module'; import { AddonModBookModule } from '@addon/mod/book/book.module'; import { AddonModChatModule } from '@addon/mod/chat/chat.module'; import { AddonModChoiceModule } from '@addon/mod/choice/choice.module'; +import { AddonModDataModule } from '@addon/mod/data/data.module'; import { AddonModLabelModule } from '@addon/mod/label/label.module'; import { AddonModLtiModule } from '@addon/mod/lti/lti.module'; import { AddonModResourceModule } from '@addon/mod/resource/resource.module'; @@ -185,6 +186,7 @@ export const CORE_PROVIDERS: any[] = [ AddonModBookModule, AddonModChatModule, AddonModChoiceModule, + AddonModDataModule, AddonModLabelModule, AddonModResourceModule, AddonModFeedbackModule, diff --git a/src/classes/delegate.ts b/src/classes/delegate.ts index 412dad239..b95626153 100644 --- a/src/classes/delegate.ts +++ b/src/classes/delegate.ts @@ -164,6 +164,20 @@ export class CoreDelegate { return enabled ? this.enabledHandlers[handlerName] : this.handlers[handlerName]; } + /** + * Check if function exists on a handler. + * + * @param {string} handlerName The handler name. + * @param {string} fnName Name of the function to execute. + * @param {booealn} [onlyEnabled=true] If check only enabled handlers or all. + * @return {any} Function returned value or default value. + */ + protected hasFunction(handlerName: string, fnName: string, onlyEnabled: boolean = true): any { + const handler = onlyEnabled ? this.enabledHandlers[handlerName] : this.handlers[handlerName]; + + return handler && handler[fnName]; + } + /** * Check if a handler name has a registered handler (not necessarily enabled). * diff --git a/src/core/course/classes/main-activity-component.ts b/src/core/course/classes/main-activity-component.ts index 5a76726cf..909a9bbb6 100644 --- a/src/core/course/classes/main-activity-component.ts +++ b/src/core/course/classes/main-activity-component.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injector } from '@angular/core'; +import { Injector, Input } from '@angular/core'; import { Content } from 'ionic-angular'; import { CoreSitesProvider } from '@providers/sites'; import { CoreCourseProvider } from '@core/course/providers/course'; @@ -26,6 +26,8 @@ import { CoreCourseModuleMainResourceComponent } from './main-resource-component * Template class to easily create CoreCourseModuleMainComponent of activities. */ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainResourceComponent { + @Input() group?: number; // Group ID the component belongs to. + moduleName: string; // Raw module name to be translated. It will be translated on init. // Data for context menu.