From c586db40dd237a86af20d4dea7c7242b3dc4c5ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 16 Mar 2018 13:44:57 +0100 Subject: [PATCH] MOBILE-2339 feedback: Implement Index page --- package.json | 1 + .../feedback/components/components.module.ts | 45 ++ .../mod/feedback/components/index/index.html | 174 ++++++ .../mod/feedback/components/index/index.ts | 435 ++++++++++++++ src/addon/mod/feedback/feedback.module.ts | 57 ++ src/addon/mod/feedback/lang/en.json | 18 + src/addon/mod/feedback/pages/index/index.html | 16 + .../mod/feedback/pages/index/index.module.ts | 33 ++ src/addon/mod/feedback/pages/index/index.ts | 52 ++ src/addon/mod/feedback/providers/feedback.ts | 548 ++++++++++++++++++ src/addon/mod/feedback/providers/helper.ts | 103 ++++ .../mod/feedback/providers/link-handler.ts | 30 + .../mod/feedback/providers/module-handler.ts | 71 +++ src/addon/mod/feedback/providers/offline.ts | 163 ++++++ .../feedback/providers/prefetch-handler.ts | 226 ++++++++ .../feedback/providers/sync-cron-handler.ts | 47 ++ src/addon/mod/feedback/providers/sync.ts | 254 ++++++++ src/app/app.module.ts | 2 + src/app/app.scss | 5 + src/components/tabs/tabs.scss | 2 +- .../course/classes/main-activity-component.ts | 35 +- .../course/classes/main-resource-component.ts | 7 +- src/core/user/providers/user.ts | 34 +- src/directives/chart.ts | 142 +++++ src/directives/directives.module.ts | 7 +- src/providers/lang.ts | 2 +- src/theme/variables.scss | 3 + 27 files changed, 2490 insertions(+), 22 deletions(-) create mode 100644 src/addon/mod/feedback/components/components.module.ts create mode 100644 src/addon/mod/feedback/components/index/index.html create mode 100644 src/addon/mod/feedback/components/index/index.ts create mode 100644 src/addon/mod/feedback/feedback.module.ts create mode 100644 src/addon/mod/feedback/lang/en.json create mode 100644 src/addon/mod/feedback/pages/index/index.html create mode 100644 src/addon/mod/feedback/pages/index/index.module.ts create mode 100644 src/addon/mod/feedback/pages/index/index.ts create mode 100644 src/addon/mod/feedback/providers/feedback.ts create mode 100644 src/addon/mod/feedback/providers/helper.ts create mode 100644 src/addon/mod/feedback/providers/link-handler.ts create mode 100644 src/addon/mod/feedback/providers/module-handler.ts create mode 100644 src/addon/mod/feedback/providers/offline.ts create mode 100644 src/addon/mod/feedback/providers/prefetch-handler.ts create mode 100644 src/addon/mod/feedback/providers/sync-cron-handler.ts create mode 100644 src/addon/mod/feedback/providers/sync.ts create mode 100644 src/directives/chart.ts diff --git a/package.json b/package.json index cc0359250..3e50bc6c7 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@types/cordova-plugin-network-information": "0.0.3", "@types/node": "^8.0.47", "@types/promise.prototype.finally": "^2.0.2", + "chart.js": "^2.7.2", "electron-builder-squirrel-windows": "^19.3.0", "electron-windows-notifications": "^1.1.13", "ionic-angular": "^3.9.2", diff --git a/src/addon/mod/feedback/components/components.module.ts b/src/addon/mod/feedback/components/components.module.ts new file mode 100644 index 000000000..511fbdbd3 --- /dev/null +++ b/src/addon/mod/feedback/components/components.module.ts @@ -0,0 +1,45 @@ +// (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 { AddonModFeedbackIndexComponent } from './index/index'; + +@NgModule({ + declarations: [ + AddonModFeedbackIndexComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCourseComponentsModule + ], + providers: [ + ], + exports: [ + AddonModFeedbackIndexComponent + ], + entryComponents: [ + AddonModFeedbackIndexComponent + ] +}) +export class AddonModFeedbackComponentsModule {} diff --git a/src/addon/mod/feedback/components/index/index.html b/src/addon/mod/feedback/components/index/index.html new file mode 100644 index 000000000..1cb45dff4 --- /dev/null +++ b/src/addon/mod/feedback/components/index/index.html @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.groupsseparate' | translate }} + {{ 'core.groupsvisible' | translate }} + + {{groupOpt.name}} + + + +

{{ 'addon.mod_feedback.completed_feedbacks' | translate }}

+ {{feedback.completedCount}} +
+ +

{{ 'addon.mod_feedback.show_nonrespondents' | translate }}

+
+ +

{{ 'addon.mod_feedback.questions' | translate }}

+ {{feedback.itemsCount}} +
+
+
+ + + + + + + +
+ + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} +
+ +
+ + {{ 'addon.mod_feedback.feedback_is_not_open' | translate }} +
+ +
+ + {{ 'addon.mod_feedback.this_feedback_is_already_submitted' | translate }} +
+ + + +

{{ 'addon.mod_feedback.feedbackopen' | translate }}

+

{{overview.openTimeReadable}}

+
+ +

{{ 'addon.mod_feedback.feedbackclose' | translate }}

+

{{overview.closeTimeReadable}}

+
+ +

{{ 'addon.mod_feedback.page_after_submit' | translate }}

+ +
+ + +

{{ 'addon.mod_feedback.mode' | translate }}

+

{{ 'addon.mod_feedback.anonymous' | translate }}

+

{{ 'addon.mod_feedback.non_anonymous' | translate }}

+
+ + + + + + + + + + + + +
+
+
+
+ + + + + + + +
+ + {{ warning }} +
+ + + +

{{item.number}}. {{ item.name }}

+

{{ item.label }}

+ + +
    +
  • {{ data }}
  • +
+

{{ 'addon.mod_feedback.average' | translate }}: {{item.average | number : '1.2-2'}}

+
+ +
    + +
  • + +
  • +
    +
+
+ + +

{{ 'addon.mod_feedback.average' | translate }}: {{item.average | number : '1.2-2'}}

+
+
+
+
+
+
+
diff --git a/src/addon/mod/feedback/components/index/index.ts b/src/addon/mod/feedback/components/index/index.ts new file mode 100644 index 000000000..8fa071427 --- /dev/null +++ b/src/addon/mod/feedback/components/index/index.ts @@ -0,0 +1,435 @@ +// (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, Optional, Injector } from '@angular/core'; +import { Content, NavController } from 'ionic-angular'; +import { CoreGroupInfo, CoreGroupsProvider } from '@providers/groups'; +import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; +import { AddonModFeedbackProvider } from '../../providers/feedback'; +import { AddonModFeedbackHelperProvider } from '../../providers/helper'; +import { AddonModFeedbackOfflineProvider } from '../../providers/offline'; +import { AddonModFeedbackSyncProvider } from '../../providers/sync'; +import * as moment from 'moment'; + +/** + * Component that displays a feedback index page. + */ +@Component({ + selector: 'addon-mod-feedback-index', + templateUrl: 'index.html', +}) +export class AddonModFeedbackIndexComponent extends CoreCourseModuleMainActivityComponent { + @Input() tab = 'overview'; + @Input() group = 0; + + moduleName = 'feedback'; + + access = { + canviewreports: false, + canviewanalysis: false, + isempty: true + }; + feedback: any; + goPage: number; + groupInfo: CoreGroupInfo = { + groups: [], + separateGroups: false, + visibleGroups: false + }; + items: any[]; + overview = { + timeopen: 0, + openTimeReadable: '', + timeclose: 0, + closeTimeReadable: '' + }; + warning = ''; + tabsLoaded = { + overview: false, + analysis: false + }; + showTabs = false; + tabsReady = false; + + protected submitObserver: any; + + constructor(injector: Injector, private feedbackProvider: AddonModFeedbackProvider, @Optional() private content: Content, + private feedbackOffline: AddonModFeedbackOfflineProvider, private groupsProvider: CoreGroupsProvider, + private feedbackSync: AddonModFeedbackSyncProvider, private navCtrl: NavController, + private feedbackHelper: AddonModFeedbackHelperProvider) { + super(injector); + + // Listen for form submit events. + this.submitObserver = this.eventsProvider.on(AddonModFeedbackProvider.FORM_SUBMITTED, (data) => { + if (this.feedback && data.feedbackId == this.feedback.id) { + // Go to review attempt if an attempt in this quiz was finished and synced. + this.tabsLoaded['analysis'] = false; + this.tabsLoaded['overview'] = false; + this.loaded = false; + if (data.tab != this.tab) { + this.tabChanged(data.tab); + } else { + this.loadContent(true); + } + } + }, this.siteId); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + super.ngOnInit(); + + this.loadContent(false, true).then(() => { + this.feedbackProvider.logView(this.feedback.id); + }).finally(() => { + this.tabsReady = true; + }); + } + + /** + * Perform the invalidate content function. + * + * @return {Promise} Resolved when done. + */ + protected invalidateContent(): Promise { + const promises = []; + + promises.push(this.feedbackProvider.invalidateFeedbackData(this.courseId)); + if (this.feedback) { + promises.push(this.feedbackProvider.invalidateFeedbackAccessInformationData(this.feedback.id)); + promises.push(this.feedbackProvider.invalidateAnalysisData(this.feedback.id)); + promises.push(this.groupsProvider.invalidateActivityAllowedGroups(this.feedback.coursemodule)); + promises.push(this.groupsProvider.invalidateActivityGroupMode(this.feedback.coursemodule)); + promises.push(this.feedbackProvider.invalidateResumePageData(this.feedback.id)); + } + + this.tabsLoaded['analysis'] = false; + this.tabsLoaded['overview'] = false; + + 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.feedback && syncEventData.feedbackId == this.feedback.id) { + // Refresh the data. + this.content.scrollToTop(); + + return true; + } + + return false; + } + + /** + * Download feedback 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 { + return this.feedbackProvider.getFeedback(this.courseId, this.module.id).then((feedback) => { + this.feedback = feedback; + + this.description = feedback.intro || feedback.description; + this.dataRetrieved.emit(feedback); + + if (sync) { + // Try to synchronize the feedback. + return this.syncActivity(showErrors); + } + }).then(() => { + // Check if there are answers stored in offline. + return this.feedbackProvider.getFeedbackAccessInformation(this.feedback.id); + }).then((accessData) => { + this.access = accessData; + this.showTabs = (accessData.canviewreports || accessData.canviewanalysis) && !accessData.isempty; + + if (this.tab == 'analysis') { + return this.fetchFeedbackAnalysisData(this.access); + } + + return this.fetchFeedbackOverviewData(this.access); + }).then(() => { + // All data obtained, now fill the context menu. + this.fillContextMenu(refresh); + + // Check if there are responses stored in offline. + return this.feedbackOffline.hasFeedbackOfflineData(this.feedback.id); + }).then((hasOffline) => { + this.hasOffline = hasOffline; + }); + } + + /** + * Convenience function to get feedback overview data. + * + * @param {any} accessData Retrieved access data. + * @return {Promise} Resolved when done. + */ + protected fetchFeedbackOverviewData(accessData: any): Promise { + const promises = []; + + if (accessData.cancomplete && accessData.cansubmit && accessData.isopen) { + promises.push(this.feedbackProvider.getResumePage(this.feedback.id).then((goPage) => { + this.goPage = goPage > 0 ? goPage : false; + })); + } + + if (accessData.canedititems) { + this.overview.timeopen = parseInt(this.feedback.timeopen) * 1000 || 0; + this.overview.openTimeReadable = this.overview.timeopen ? + moment(this.overview.timeopen).format('LLL') : ''; + this.overview.timeclose = parseInt(this.feedback.timeclose) * 1000 || 0; + this.overview.closeTimeReadable = this.overview.timeclose ? + moment(this.overview.timeclose).format('LLL') : ''; + + // Get groups (only for teachers). + promises.push(this.fetchGroupInfo(this.feedback.coursemodule)); + } + + return Promise.all(promises).finally(() => { + this.tabsLoaded['overview'] = true; + }); + } + + /** + * Convenience function to get feedback analysis data. + * + * @param {any} accessData Retrieved access data. + * @return {Promise} Resolved when done. + */ + protected fetchFeedbackAnalysisData(accessData: any): Promise { + let promise; + + if (accessData.canviewanalysis) { + // Get groups (only for teachers). + promise = this.fetchGroupInfo(this.feedback.coursemodule); + } else { + this.tabChanged('overview'); + promise = Promise.resolve(); + } + + return promise.finally(() => { + this.tabsLoaded['analysis'] = true; + }); + } + + /** + * Fetch Group info data. + * + * @param {number} cmId Course module ID. + * @return {Promise} Resolved when done. + */ + protected fetchGroupInfo(cmId: number): Promise { + return this.groupsProvider.getActivityGroupInfo(cmId).then((groupInfo) => { + this.groupInfo = groupInfo; + + return this.setGroup(this.group); + }); + } + + /** + * Parse the analysis info to show the info correctly formatted. + * + * @param {any} item Item to parse. + * @return {any} Parsed item. + */ + protected parseAnalysisInfo(item: any): any { + switch (item.typ) { + case 'numeric': + item.average = item.data.reduce((prev, current) => { + return prev + parseInt(current, 10); + }, 0) / item.data.length; + item.template = 'numeric'; + break; + + case 'info': + item.data = item.data.map((dataItem) => { + dataItem = this.textUtils.parseJSON(dataItem); + + return typeof dataItem.show != 'undefined' ? dataItem.show : false; + }).filter((dataItem) => { + // Filter false entries. + return dataItem; + }); + + case 'textfield': + case 'textarea': + item.template = 'list'; + break; + + case 'multichoicerated': + case 'multichoice': + item.data = item.data.map((dataItem) => { + dataItem = this.textUtils.parseJSON(dataItem); + + return typeof dataItem.answertext != 'undefined' ? dataItem : false; + }).filter((dataItem) => { + // Filter false entries. + return dataItem; + }); + + // Format labels. + item.labels = item.data.map((dataItem) => { + dataItem.quotient = (dataItem.quotient * 100).toFixed(2); + let label = ''; + + if (typeof dataItem.value != 'undefined') { + label = '(' + dataItem.value + ') '; + } + label += dataItem.answertext; + label += dataItem.quotient > 0 ? ' (' + dataItem.quotient + '%)' : ''; + + return label; + }); + + item.chartData = item.data.map((dataItem) => { + return dataItem.answercount; + }); + + if (item.typ == 'multichoicerated') { + item.average = item.data.reduce((prev, current) => { + return prev + parseFloat(current.avg); + }, 0.0); + } + + const subtype = item.presentation.charAt(0); + + const single = subtype != 'c'; + item.chartType = single ? 'doughnut' : 'bar'; + + item.template = 'chart'; + break; + + default: + break; + } + + return item; + } + + /** + * Function to go to the questions form. + * + * @param {boolean} preview Preview or edit the form. + */ + gotoAnswerQuestions(preview: boolean): void { + const stateParams = { + module: this.module, + moduleid: this.module.id, + courseid: this.courseId, + preview: preview + }; + this.navCtrl.push('AddonModFeedbackFormPage', stateParams); + } + + /** + * Function to link implemented features. + * + * @param {string} feature Feature to navigate. + */ + openFeature(feature: string): void { + this.feedbackHelper.openFeature(feature, this.navCtrl, this.module, this.courseId, this.group); + } + + /** + * Tab changed, fetch content again. + * + * @param {string} tabName New tab name. + */ + tabChanged(tabName: string): void { + this.tab = tabName; + + if (!this.tabsLoaded[this.tab]) { + this.loadContent(false, false, true); + } + } + + /** + * Set group to see the analysis. + * + * @param {number} groupId Group ID. + * @return {Promise} Resolved when done. + */ + setGroup(groupId: number): Promise { + this.group = groupId; + + return this.feedbackProvider.getAnalysis(this.feedback.id, groupId).then((analysis) => { + this.feedback.completedCount = analysis.completedcount; + this.feedback.itemsCount = analysis.itemscount; + + if (this.tab == 'analysis') { + let num = 1; + + this.items = analysis.itemsdata.map((item) => { + // Move data inside item. + item.item.data = item.data; + item = item.item; + item.number = num++; + if (item.data && item.data.length) { + return this.parseAnalysisInfo(item); + } + + return false; + }).filter((item) => { + return item; + }); + + this.warning = ''; + if (analysis.warnings.length) { + this.warning = analysis.warnings.find((warning) => { + return warning.warningcode == 'insufficientresponsesforthisgroup'; + }); + } + } + }); + } + + /** + * Performs the sync of the activity. + * + * @return {Promise} Promise resolved when done. + */ + protected sync(): Promise { + return this.feedbackSync.syncFeedback(this.feedback.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.submitObserver && this.submitObserver.off(); + } +} diff --git a/src/addon/mod/feedback/feedback.module.ts b/src/addon/mod/feedback/feedback.module.ts new file mode 100644 index 000000000..2c412f694 --- /dev/null +++ b/src/addon/mod/feedback/feedback.module.ts @@ -0,0 +1,57 @@ +// (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 { AddonModFeedbackComponentsModule } from './components/components.module'; +import { AddonModFeedbackModuleHandler } from './providers/module-handler'; +import { AddonModFeedbackProvider } from './providers/feedback'; +import { AddonModFeedbackLinkHandler } from './providers/link-handler'; +import { AddonModFeedbackHelperProvider } from './providers/helper'; +import { AddonModFeedbackPrefetchHandler } from './providers/prefetch-handler'; +import { AddonModFeedbackSyncProvider } from './providers/sync'; +import { AddonModFeedbackSyncCronHandler } from './providers/sync-cron-handler'; +import { AddonModFeedbackOfflineProvider } from './providers/offline'; + +@NgModule({ + declarations: [ + ], + imports: [ + AddonModFeedbackComponentsModule + ], + providers: [ + AddonModFeedbackProvider, + AddonModFeedbackModuleHandler, + AddonModFeedbackPrefetchHandler, + AddonModFeedbackHelperProvider, + AddonModFeedbackLinkHandler, + AddonModFeedbackSyncCronHandler, + AddonModFeedbackSyncProvider, + AddonModFeedbackOfflineProvider + ] +}) +export class AddonModFeedbackModule { + constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModFeedbackModuleHandler, + prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModFeedbackPrefetchHandler, + contentLinksDelegate: CoreContentLinksDelegate, linkHandler: AddonModFeedbackLinkHandler, + cronDelegate: CoreCronDelegate, syncHandler: AddonModFeedbackSyncCronHandler) { + moduleDelegate.registerHandler(moduleHandler); + prefetchDelegate.registerHandler(prefetchHandler); + contentLinksDelegate.registerHandler(linkHandler); + cronDelegate.register(syncHandler); + } +} diff --git a/src/addon/mod/feedback/lang/en.json b/src/addon/mod/feedback/lang/en.json new file mode 100644 index 000000000..113036f44 --- /dev/null +++ b/src/addon/mod/feedback/lang/en.json @@ -0,0 +1,18 @@ +{ + "analysis": "Analysis", + "anonymous": "Anonymous", + "average": "Average", + "completed_feedbacks": "Submitted answers", + "complete_the_form": "Answer the questions...", + "continue_the_form": "Continue the form", + "feedbackclose": "Allow answers to", + "feedbackopen": "Allow answers from", + "mode": "Mode", + "non_anonymous": "User's name will be logged and shown with answers", + "overview": "Overview", + "page_after_submit": "Completion message", + "preview": "Preview", + "questions": "Questions", + "show_nonrespondents": "Show non-respondents", + "this_feedback_is_already_submitted": "You've already completed this activity." +} \ No newline at end of file diff --git a/src/addon/mod/feedback/pages/index/index.html b/src/addon/mod/feedback/pages/index/index.html new file mode 100644 index 000000000..f404b50de --- /dev/null +++ b/src/addon/mod/feedback/pages/index/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/addon/mod/feedback/pages/index/index.module.ts b/src/addon/mod/feedback/pages/index/index.module.ts new file mode 100644 index 000000000..5b7451a7f --- /dev/null +++ b/src/addon/mod/feedback/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 { AddonModFeedbackComponentsModule } from '../../components/components.module'; +import { AddonModFeedbackIndexPage } from './index'; + +@NgModule({ + declarations: [ + AddonModFeedbackIndexPage, + ], + imports: [ + CoreDirectivesModule, + AddonModFeedbackComponentsModule, + IonicPageModule.forChild(AddonModFeedbackIndexPage), + TranslateModule.forChild() + ], +}) +export class AddonModFeedbackIndexPageModule {} diff --git a/src/addon/mod/feedback/pages/index/index.ts b/src/addon/mod/feedback/pages/index/index.ts new file mode 100644 index 000000000..a6b937d91 --- /dev/null +++ b/src/addon/mod/feedback/pages/index/index.ts @@ -0,0 +1,52 @@ +// (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 { AddonModFeedbackIndexComponent } from '../../components/index/index'; + +/** + * Page that displays a feedback. + */ +@IonicPage({ segment: 'addon-mod-feedback-index' }) +@Component({ + selector: 'page-addon-mod-feedback-index', + templateUrl: 'index.html', +}) +export class AddonModFeedbackIndexPage { + @ViewChild(AddonModFeedbackIndexComponent) feedbackComponent: AddonModFeedbackIndexComponent; + + title: string; + module: any; + courseId: number; + selectedTab: string; + selectedGroup: number; + + constructor(navParams: NavParams) { + this.module = navParams.get('module') || {}; + this.courseId = navParams.get('courseId'); + this.selectedGroup = navParams.get('group') || 0; + this.selectedTab = navParams.get('tab') || 'overview'; + this.title = this.module.name; + } + + /** + * Update some data based on the feedback instance. + * + * @param {any} feedback Feedback instance. + */ + updateData(feedback: any): void { + this.title = feedback.name || this.title; + } +} diff --git a/src/addon/mod/feedback/providers/feedback.ts b/src/addon/mod/feedback/providers/feedback.ts new file mode 100644 index 000000000..e954024d8 --- /dev/null +++ b/src/addon/mod/feedback/providers/feedback.ts @@ -0,0 +1,548 @@ +// (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'; + +/** + * Service that provides some features for feedbacks. + */ +@Injectable() +export class AddonModFeedbackProvider { + static COMPONENT = 'mmaModFeedback'; + static FORM_SUBMITTED = 'addon_mod_feedback_form_submitted'; + + protected ROOT_CACHE_KEY = this.ROOT_CACHE_KEY + ''; + protected logger; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, + private filepoolProvider: CoreFilepoolProvider) { + this.logger = logger.getInstance('AddonModFeedbackProvider'); + } + + /** + * Get analysis information for a given feedback. + * + * @param {number} feedbackId Feedback ID. + * @param {number} [groupId] Group ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the feedback is retrieved. + */ + getAnalysis(feedbackId: number, groupId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + feedbackid: feedbackId + }, + preSets = { + cacheKey: this.getAnalysisDataCacheKey(feedbackId, groupId) + }; + + if (groupId) { + params['groupid'] = groupId; + } + + return site.read('mod_feedback_get_analysis', params, preSets); + }); + } + + /** + * Get cache key for feedback analysis data WS calls. + * + * @param {number} feedbackId Feedback ID. + * @param {number} [groupId=0] Group ID. + * @return {string} Cache key. + */ + protected getAnalysisDataCacheKey(feedbackId: number, groupId: number = 0): string { + return this.getAnalysisDataPrefixCacheKey(feedbackId) + groupId; + } + + /** + * Get prefix cache key for feedback analysis data WS calls. + * + * @param {number} feedbackId Feedback ID. + * @return {string} Cache key. + */ + protected getAnalysisDataPrefixCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':analysis:'; + } + + /** + * Get prefix cache key for feedback completion data WS calls. + * + * @param {number} feedbackId Feedback ID. + * @return {string} Cache key. + */ + protected getCompletedDataCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':completed:'; + } + + /** + * Returns the temporary completion timemodified for the current user. + * + * @param {number} feedbackId Feedback ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the info is retrieved. + */ + getCurrentCompletedTimeModified(feedbackId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + feedbackid: feedbackId + }, + preSets = { + cacheKey: this.getCurrentCompletedTimeModifiedDataCacheKey(feedbackId) + }; + + return site.read('mod_feedback_get_current_completed_tmp', params, preSets).then((response) => { + if (response && typeof response.feedback != 'undefined' && typeof response.feedback.timemodified != 'undefined') { + return response.feedback.timemodified; + } + + return 0; + }).catch(() => { + // Ignore errors. + return 0; + }); + }); + } + + /** + * Get prefix cache key for feedback current completed temp data WS calls. + * + * @param {number} feedbackId Feedback ID. + * @return {string} Cache key. + */ + protected getCurrentCompletedTimeModifiedDataCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':completedtime:'; + } + + /** + * Returns the temporary completion record for the current user. + * + * @param {number} feedbackId Feedback 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 always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the info is retrieved. + */ + getCurrentValues(feedbackId: number, offline: boolean = false, ignoreCache: boolean = false, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + feedbackid: feedbackId + }, + preSets = { + cacheKey: this.getCurrentValuesDataCacheKey(feedbackId) + }; + + if (offline) { + preSets['omitExpires'] = true; + } else if (ignoreCache) { + preSets['getFromCache'] = false; + preSets['emergencyCache'] = false; + } + + return site.read('mod_feedback_get_unfinished_responses', params, preSets).then((response) => { + if (response && typeof response.responses != 'undefined') { + return response.responses; + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for get current values feedback data WS calls. + * + * @param {number} feedbackId Feedback ID. + * @return {string} Cache key. + */ + protected getCurrentValuesDataCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':currentvalues'; + } + + /** + * Get access information for a given feedback. + * + * @param {number} feedbackId Feedback 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 always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the feedback is retrieved. + */ + getFeedbackAccessInformation(feedbackId: number, offline: boolean = false, ignoreCache: boolean = false, siteId?: string): + Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + feedbackid: feedbackId + }, + preSets = { + cacheKey: this.getFeedbackAccessInformationDataCacheKey(feedbackId) + }; + + if (offline) { + preSets['omitExpires'] = true; + } else if (ignoreCache) { + preSets['getFromCache'] = false; + preSets['emergencyCache'] = false; + } + + return site.read('mod_feedback_get_feedback_access_information', params, preSets); + }); + } + + /** + * Get cache key for feedback access information data WS calls. + * + * @param {number} feedbackId Feedback ID. + * @return {string} Cache key. + */ + protected getFeedbackAccessInformationDataCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':access'; + } + + /** + * Get cache key for feedback data WS calls. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getFeedbackCacheKey(courseId: number): string { + return this.ROOT_CACHE_KEY + 'feedback:' + courseId; + } + + /** + * Get prefix cache key for all feedback activity data WS calls. + * + * @param {number} feedbackId Feedback ID. + * @return {string} Cache key. + */ + protected getFeedbackDataPrefixCacheKey(feedbackId: number): string { + return this.ROOT_CACHE_KEY + feedbackId; + } + + /** + * Get a feedback with key=value. 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 feedback is retrieved. + */ + protected getFeedbackDataByKey(courseId: number, key: string, value: any, siteId?: string, forceCache?: boolean): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + courseids: [courseId] + }, + preSets = { + cacheKey: this.getFeedbackCacheKey(courseId) + }; + + if (forceCache) { + preSets['omitExpires'] = true; + } + + return site.read('mod_feedback_get_feedbacks_by_courses', params, preSets).then((response) => { + if (response && response.feedbacks) { + const currentFeedback = response.feedbacks.find((feedback) => { + return feedback[key] == value; + }); + if (currentFeedback) { + return currentFeedback; + } + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get a feedback 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] True to always get the value from cache, false otherwise. Default false. + * @return {Promise} Promise resolved when the feedback is retrieved. + */ + getFeedback(courseId: number, cmId: number, siteId?: string, forceCache?: boolean): Promise { + return this.getFeedbackDataByKey(courseId, 'coursemodule', cmId, siteId, forceCache); + } + + /** + * Get a feedback by ID. + * + * @param {number} courseId Course ID. + * @param {number} id Feedback ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {boolean} [forceCache] True to always get the value from cache, false otherwise. Default false. + * @return {Promise} Promise resolved when the feedback is retrieved. + */ + getFeedbackById(courseId: number, id: number, siteId?: string, forceCache?: boolean): Promise { + return this.getFeedbackDataByKey(courseId, 'id', id, siteId, forceCache); + } + + /** + * Gets the resume page information. + * + * @param {number} feedbackId Feedback 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 always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the info is retrieved. + */ + getResumePage(feedbackId: number, offline: boolean = false, ignoreCache: boolean = false, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + feedbackid: feedbackId + }, + preSets = { + cacheKey: this.getResumePageDataCacheKey(feedbackId) + }; + + if (offline) { + preSets['omitExpires'] = true; + } else if (ignoreCache) { + preSets['getFromCache'] = false; + preSets['emergencyCache'] = false; + } + + return site.read('mod_feedback_launch_feedback', params, preSets).then((response) => { + if (response && typeof response.gopage != 'undefined') { + // WS will return -1 for last page but the user need to start again. + return response.gopage > 0 ? response.gopage : 0; + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get prefix cache key for resume feedback page data WS calls. + * + * @param {number} feedbackId Feedback ID. + * @return {string} Cache key. + */ + protected getResumePageDataCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':launch'; + } + + /** + * Invalidates feedback data except files and module info. + * + * @param {number} feedbackId Feedback ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAllFeedbackData(feedbackId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getFeedbackDataPrefixCacheKey(feedbackId)); + }); + } + + /** + * Invalidates feedback analysis data. + * + * @param {number} feedbackId Feedback ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAnalysisData(feedbackId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getAnalysisDataPrefixCacheKey(feedbackId)); + }); + } + + /** + * Invalidate the prefetched content. + * To invalidate files, use AddonFeedbackProvider#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.getFeedback(courseId, moduleId, siteId).then((feedback) => { + const ps = []; + + // Do not invalidate module data before getting module info, we need it! + ps.push(this.invalidateFeedbackData(courseId, siteId)); + ps.push(this.invalidateAllFeedbackData(feedback.id, siteId)); + + return Promise.all(ps); + })); + + promises.push(this.invalidateFiles(moduleId, siteId)); + + return this.utils.allPromises(promises); + } + + /** + * Invalidates temporary completion record data. + * + * @param {number} feedbackId Feedback ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateCurrentValuesData(feedbackId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCurrentValuesDataCacheKey(feedbackId)); + }); + } + + /** + * Invalidates feedback access information data. + * + * @param {number} feedbackId Feedback ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateFeedbackAccessInformationData(feedbackId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getFeedbackAccessInformationDataCacheKey(feedbackId)); + }); + } + + /** + * Invalidates feedback 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. + */ + invalidateFeedbackData(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getFeedbackCacheKey(courseId)); + }); + } + + /** + * 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, AddonModFeedbackProvider.COMPONENT, moduleId); + } + + /** + * Invalidates launch feedback data. + * + * @param {number} feedbackId Feedback ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateResumePageData(feedbackId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getResumePageDataCacheKey(feedbackId)); + }); + } + + /** + * Returns if feedback has been completed + * + * @param {number} feedbackId Feedback ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the info is retrieved. + */ + isCompleted(feedbackId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + feedbackid: feedbackId + }, + preSets = { + cacheKey: this.getCompletedDataCacheKey(feedbackId) + }; + + return this.utils.promiseWorks(site.read('mod_feedback_get_last_completed', params, preSets)); + }); + } + + /** + * Return whether or not the plugin is enabled in a certain site. Plugin is enabled if the feedback 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_feedback_get_feedbacks_by_courses') && + site.wsAvailable('mod_feedback_get_feedback_access_information'); + }); + } + + /** + * Report the feedback as being viewed. + * + * @param {number} id Module ID. + * @param {boolean} [formViewed=false] True if form was viewed. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logView(id: number, formViewed: boolean = false): Promise { + const params = { + feedbackid: id, + moduleviewed: formViewed ? 1 : 0 + }; + + return this.sitesProvider.getCurrentSite().write('mod_feedback_view_feedback', params); + } + + /** + * Process a jump between pages. + * + * @param {number} feedbackId Feedback ID. + * @param {number} page The page being processed. + * @param {any} responses The data to be processed the key is the field name (usually type[index]_id). + * @param {boolean} goPrevious Whether we want to jump to previous page. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the info is retrieved. + */ + processPageOnline(feedbackId: number, page: number, responses: any, goPrevious: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + feedbackid: feedbackId, + page: page, + responses: this.utils.objectToArrayOfObjects(responses, 'name', 'value'), + goprevious: goPrevious ? 1 : 0 + }; + + return site.write('mod_feedback_process_page', params).catch((error) => { + return this.utils.createFakeWSError(error); + }).then((response) => { + // Invalidate and update current values because they will change. + return this.invalidateCurrentValuesData(feedbackId, site.getId()).then(() => { + return this.getCurrentValues(feedbackId, false, false, site.getId()); + }).catch(() => { + // Ignore errors. + }).then(() => { + return response; + }); + }); + }); + } +} diff --git a/src/addon/mod/feedback/providers/helper.ts b/src/addon/mod/feedback/providers/helper.ts new file mode 100644 index 000000000..7d83e1fd1 --- /dev/null +++ b/src/addon/mod/feedback/providers/helper.ts @@ -0,0 +1,103 @@ +// (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 } from 'ionic-angular'; + +/** + * Service that provides helper functions for feedbacks. + */ +@Injectable() +export class AddonModFeedbackHelperProvider { + + /** + * Check if the page we are going to open is in the history and returns the number of pages in the stack to go back. + * + * @param {string} pageName Name of the page we want to navigate. + * @param {number} instance Activity instance Id. I.e FeedbackId. + * @param {string} paramName Param name where to find the instance number. + * @param {string} prefix Prefix to check if we are out of the activity context. + * @return {number} Returns the number of times the history needs to go back to find the specified page. + */ + protected getActivityHistoryBackCounter(pageName: string, instance: number, paramName: string, prefix: string, + navCtrl: NavController): number { + let historyInstance, params, + backTimes = 0, + view = navCtrl.getActive(); + + while (!view.isFirst()) { + if (!view.name.startsWith(prefix)) { + break; + } + + params = view.getNavParams(); + + historyInstance = params.get(paramName) ? params.get(paramName) : params.get('module').instance; + + // Check we are not changing to another activity. + if (historyInstance && historyInstance == instance) { + backTimes++; + } else { + break; + } + + // Page found. + if (view.name == pageName) { + return view.index; + } + + view = navCtrl.getPrevious(view); + } + + return 0; + } + + /** + * Helper function to open a feature in the app. + * + * @param {string} feature Name of the feature to open. + * @param {NavController} navCtrl NavController. + * @param {any} module Course module activity object. + * @param {number} courseId Course Id. + * @param {number} [group=0] Course module activity object. + * @return {Promise} Resolved when navigation animation is done. + */ + openFeature(feature: string, navCtrl: NavController, module: any, courseId: number, group: number = 0): Promise { + const pageName = feature && feature != 'analysis' ? 'AddonModFeedback' + feature + 'Page' : 'AddonModFeedbackIndexPage'; + let backTimes = 0; + + const stateParams = { + module: module, + moduleId: module.id, + courseId: courseId, + feedbackId: module.instance, + group: group + }; + + // Only check history if navigating through tabs. + if (pageName == 'AddonModFeedbackIndexPage') { + stateParams['tab'] = feature == 'analysis' ? 'analysis' : 'overview'; + backTimes = this.getActivityHistoryBackCounter(pageName, module.instance, 'feedbackId', 'AddonModFeedback', navCtrl); + } + + if (backTimes > 0) { + // Go back X times until the the page we want to reach. + return navCtrl.remove(navCtrl.getActive().index, backTimes); + } + + // Not found, open new state. + return navCtrl.push(pageName, stateParams); + } + +} diff --git a/src/addon/mod/feedback/providers/link-handler.ts b/src/addon/mod/feedback/providers/link-handler.ts new file mode 100644 index 000000000..e4df7edf0 --- /dev/null +++ b/src/addon/mod/feedback/providers/link-handler.ts @@ -0,0 +1,30 @@ +// (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'; +import { AddonModFeedbackProvider } from './feedback'; + +/** + * Handler to treat links to feedback. + */ +@Injectable() +export class AddonModFeedbackLinkHandler extends CoreContentLinksModuleIndexHandler { + name = 'AddonModFeedbackLinkHandler'; + + constructor(courseHelper: CoreCourseHelperProvider) { + super(courseHelper, AddonModFeedbackProvider.COMPONENT, 'feedback'); + } +} diff --git a/src/addon/mod/feedback/providers/module-handler.ts b/src/addon/mod/feedback/providers/module-handler.ts new file mode 100644 index 000000000..725565779 --- /dev/null +++ b/src/addon/mod/feedback/providers/module-handler.ts @@ -0,0 +1,71 @@ +// (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 { AddonModFeedbackIndexComponent } from '../components/index/index'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { AddonModFeedbackProvider } from './feedback'; + +/** + * Handler to support feedback modules. + */ +@Injectable() +export class AddonModFeedbackModuleHandler implements CoreCourseModuleHandler { + name = 'feedback'; + + constructor(private courseProvider: CoreCourseProvider, private feedbackProvider: AddonModFeedbackProvider) { } + + /** + * 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.feedbackProvider.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('feedback'), + title: module.name, + class: 'addon-mod_feedback-handler', + showDownloadButton: true, + action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { + navCtrl.push('AddonModFeedbackIndexPage', {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 AddonModFeedbackIndexComponent; + } +} diff --git a/src/addon/mod/feedback/providers/offline.ts b/src/addon/mod/feedback/providers/offline.ts new file mode 100644 index 000000000..e905a6d22 --- /dev/null +++ b/src/addon/mod/feedback/providers/offline.ts @@ -0,0 +1,163 @@ +// (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 { CoreTimeUtilsProvider } from '@providers/utils/time'; + +/** + * Service to handle Offline feedback. + */ +@Injectable() +export class AddonModFeedbackOfflineProvider { + + protected logger; + + // Variables for database. + protected FEEDBACK_TABLE = 'mma_mod_feedback_answers'; + protected tablesSchema = [ + { + name: this.FEEDBACK_TABLE, + columns: [ + { + name: 'feedbackid', + type: 'INTEGER' + }, + { + name: 'page', + type: 'TEXT' + }, + { + name: 'courseid', + type: 'INTEGER' + }, + { + name: 'responses', + type: 'TEXT' + }, + { + name: 'timemodified', + type: 'INTEGER' + } + ], + primaryKeys: ['feedbackid', 'page'] + } + ]; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, + private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider) { + this.logger = logger.getInstance('AddonModFeedbackOfflineProvider'); + this.sitesProvider.createTablesFromSchema(this.tablesSchema); + } + + /** + * Delete the stored for a certain feedback page. + * + * @param {number} feedbackId Feedback ID. + * @param {number} page Page of the form to delete responses from. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + deleteFeedbackPageResponses(feedbackId: number, page: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(this.FEEDBACK_TABLE, {feedbackid: feedbackId, page: page}); + }); + } + + /** + * Get all the stored feedback responses data from all the feedback. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with entries. + */ + getAllFeedbackResponses(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getAllRecords(this.FEEDBACK_TABLE).then((entries) => { + return entries.map((entry) => { + entry.responses = this.textUtils.parseJSON(entry.responses); + }); + }); + }); + } + + /** + * Get all the stored responses from a certain feedback. + * + * @param {number} feedbackId Feedback ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with responses. + */ + getFeedbackResponses(feedbackId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.FEEDBACK_TABLE, {feedbackid: feedbackId}); + }); + } + + /** + * Get the stored responses for a certain feedback page. + * + * @param {number} feedbackId Feedback ID. + * @param {number} page Page of the form to get responses from. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with responses. + */ + getFeedbackPageResponses(feedbackId: number, page: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecord(this.FEEDBACK_TABLE, {feedbackid: feedbackId, page: page}).then((entry) => { + entry.responses = this.textUtils.parseJSON(entry.responses); + + return entry; + }); + }); + } + + /** + * Get if the feedback have something to be synced. + * + * @param {number} feedbackId Feedback ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if the feedback have something to be synced. + */ + hasFeedbackOfflineData(feedbackId: number, siteId?: string): Promise { + return this.getFeedbackResponses(feedbackId, siteId).then((responses) => { + return !!responses.length; + }); + } + + /** + * Save page responses to be sent later. + * + * @param {number} feedbackId Feedback ID. + * @param {number} page The page being processed. + * @param {any} responses The data to be processed the key is the field name (usually type[index]_id) + * @param {number} courseId Course ID the feedback belongs to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + saveResponses(feedbackId: number, page: number, responses: any, courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const entry = { + feedbackid: feedbackId, + page: page, + courseid: courseId, + responses: JSON.stringify(responses), + timemodified: this.timeUtils.timestamp() + }; + + return site.getDb().insertOrUpdateRecord(this.FEEDBACK_TABLE, entry, {feedbackid: feedbackId, page: page}); + }); + } +} diff --git a/src/addon/mod/feedback/providers/prefetch-handler.ts b/src/addon/mod/feedback/providers/prefetch-handler.ts new file mode 100644 index 000000000..e2526b837 --- /dev/null +++ b/src/addon/mod/feedback/providers/prefetch-handler.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 { Injectable, Injector } from '@angular/core'; +import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler'; +import { AddonModFeedbackProvider } from './feedback'; +import { AddonModFeedbackHelperProvider } from './helper'; +import { CoreFilepoolProvider } from '@providers/filepool'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreUserProvider } from '@core/user/providers/user'; + +/** + * Handler to prefetch feedbacks. + */ +@Injectable() +export class AddonModFeedbackPrefetchHandler extends CoreCourseModulePrefetchHandlerBase { + name = 'feedback'; + component = AddonModFeedbackProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^attemptsfinished|^attemptsunfinished$/; + + constructor(injector: Injector, protected feedbackProvider: AddonModFeedbackProvider, protected userProvider: CoreUserProvider, + protected filepoolProvider: CoreFilepoolProvider, protected feedbackHelper: AddonModFeedbackHelperProvider, + protected timeUtils: CoreTimeUtilsProvider, protected groupsProvider: CoreGroupsProvider) { + 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 feedback. + * @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 = [], + siteId = this.sitesProvider.getCurrentSiteId(); + + promises.push(super.downloadOrPrefetch(module, courseId, prefetch)); + promises.push(this.feedbackProvider.getFeedback(courseId, module.id).then((feedback) => { + const p1 = []; + + p1.push(this.getFiles(module, courseId).then((files) => { + return this.filepoolProvider.addFilesToQueue(siteId, files, this.component, module.id); + })); + + p1.push(this.feedbackProvider.getFeedbackAccessInformation(feedback.id, false, true, siteId).then((accessData) => { + const p2 = []; + if (accessData.canedititems || accessData.canviewreports) { + // Get all groups analysis. + p2.push(this.feedbackProvider.getAnalysis(feedback.id, undefined, siteId)); + p2.push(this.groupsProvider.getActivityGroupInfo(feedback.coursemodule, true, undefined, siteId) + .then((groupInfo) => { + const p3 = [], + userIds = []; + + if (!groupInfo.groups || groupInfo.groups.length == 0) { + groupInfo.groups = [{id: 0}]; + } + groupInfo.groups.forEach((group) => { + p3.push(this.feedbackProvider.getAnalysis(feedback.id, group.id, siteId)); + p3.push(this.feedbackProvider.getAllResponsesAnalysis(feedback.id, group.id, siteId) + .then((responses) => { + responses.attempts.forEach((attempt) => { + userIds.push(attempt.userid); + }); + })); + + if (!accessData.isanonymous) { + p3.push(this.feedbackProvider.getAllNonRespondents(feedback.id, group.id, siteId) + .then((responses) => { + responses.users.forEach((user) => { + userIds.push(user.userid); + }); + })); + } + }); + + return Promise.all(p3).then(() => { + // Prefetch user profiles. + return this.userProvider.prefetchProfiles(userIds, courseId, siteId); + }); + })); + } + + p2.push(this.feedbackProvider.getItems(feedback.id, siteId)); + + if (accessData.cancomplete && accessData.cansubmit && !accessData.isempty) { + // Send empty data, so it will recover last completed feedback attempt values. + p2.push(this.feedbackProvider.processPageOnline(feedback.id, 0, {}, undefined, siteId).finally(() => { + const p4 = []; + + p4.push(this.feedbackProvider.getCurrentValues(feedback.id, false, true, siteId)); + p4.push(this.feedbackProvider.getResumePage(feedback.id, false, true, siteId)); + + return Promise.all(p4); + })); + } + + return Promise.all(p2); + })); + + return Promise.all(p1); + })); + + return Promise.all(promises); + }*/ + + /** + * Get the list of downloadable files. + * + * @param {any} module Module to get the files. + * @param {number} courseId Course ID the module belongs to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the list of files. + */ + /*getFiles(module: any, courseId: number, single?: boolean): Promise { + let files = []; + + return this.feedbackProvider.getFeedback(courseId, module.id).then((feedback) => { + + // Get intro files and page after submit files. + files = feedback.pageaftersubmitfiles || []; + files = files.concat(this.getIntroFilesFromInstance(module, feedback)); + + return this.feedbackProvider.getItems(feedback.id); + }).then((response) => { + response.items.forEach((item) => { + files = files.concat(item.itemfiles); + }); + + return files; + }).catch(() => { + // Any error, return the list we have. + return files; + }); + }*/ + + /** + * Returns feedback 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.feedbackProvider.getFeedback(courseId, module.id).catch(() => { + // Not found, return undefined so module description is used. + }).then((feedback) => { + return this.getIntroFilesFromInstance(module, feedback); + }); + } + + /** + * 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.feedbackProvider.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.feedbackProvider.invalidateFeedbackData(courseId); + } + + /** + * Check if a feedback is downloadable. + * A feedback isn't downloadable if it's not open yet. + * Closed feedback are downloadable because teachers can always see the results. + * + * @param {any} module Module to check. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved with true if downloadable, resolved with false otherwise. + */ + isDownloadable(module: any, courseId: number): boolean | Promise { + return this.feedbackProvider.getFeedback(courseId, module.id, undefined, true).then((feedback) => { + const now = this.timeUtils.timestamp(); + + // Check time first if available. + if (feedback.timeopen && feedback.timeopen > now) { + return false; + } + if (feedback.timeclose && feedback.timeclose < now) { + return false; + } + + return this.feedbackProvider.getFeedbackAccessInformation(feedback.id).then((accessData) => { + return accessData.isopen; + }); + }); + } + + /** + * 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.feedbackProvider.isPluginEnabled(); + } +} diff --git a/src/addon/mod/feedback/providers/sync-cron-handler.ts b/src/addon/mod/feedback/providers/sync-cron-handler.ts new file mode 100644 index 000000000..e1c93fc95 --- /dev/null +++ b/src/addon/mod/feedback/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 { AddonModFeedbackSyncProvider } from './sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonModFeedbackSyncCronHandler implements CoreCronHandler { + name = 'AddonModFeedbackSyncCronHandler'; + + constructor(private feedbackSync: AddonModFeedbackSyncProvider) {} + + /** + * 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.feedbackSync.syncAllFeedbacks(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/feedback/providers/sync.ts b/src/addon/mod/feedback/providers/sync.ts new file mode 100644 index 000000000..143ed10a5 --- /dev/null +++ b/src/addon/mod/feedback/providers/sync.ts @@ -0,0 +1,254 @@ +// (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 { AddonModFeedbackOfflineProvider } from './offline'; +import { AddonModFeedbackProvider } from './feedback'; +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 feedbacks. + */ +@Injectable() +export class AddonModFeedbackSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'addon_mod_feedback_autom_synced'; + protected componentTranslate: string; + + constructor(protected sitesProvider: CoreSitesProvider, protected loggerProvider: CoreLoggerProvider, + protected appProvider: CoreAppProvider, private feedbackOffline: AddonModFeedbackOfflineProvider, + private eventsProvider: CoreEventsProvider, private feedbackProvider: AddonModFeedbackProvider, + private translate: TranslateService, private utils: CoreUtilsProvider, protected textUtils: CoreTextUtilsProvider, + courseProvider: CoreCourseProvider, syncProvider: CoreSyncProvider) { + super('AddonModFeedbackSyncProvider', sitesProvider, loggerProvider, appProvider, syncProvider, textUtils); + this.componentTranslate = courseProvider.translateModuleName('feedback'); + } + + /** + * Try to synchronize all the feedbacks 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. + */ + syncAllFeedbacks(siteId?: string): Promise { + return this.syncOnSites('all feedbacks', this.syncAllFeedbacksFunc.bind(this), undefined, siteId); + } + + /** + * Sync all pending feedbacks 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 syncAllFeedbacksFunc(siteId?: string): Promise { + // Sync all new responses. + return this.feedbackOffline.getAllFeedbackResponses(siteId).then((responses) => { + const promises = {}; + + // Do not sync same feedback twice. + for (const i in responses) { + const response = responses[i]; + + if (typeof promises[response.feedbackid] != 'undefined') { + continue; + } + + promises[response.feedbackid] = this.syncFeedbackIfNeeded(response.feedbackid, siteId).then((result) => { + if (result && result.updated) { + // Sync successful, send event. + this.eventsProvider.trigger(AddonModFeedbackSyncProvider.AUTO_SYNCED, { + feedbackId: response.feedbackid, + userId: response.userid, + warnings: result.warnings + }, siteId); + } + }); + } + + // Promises will be an object so, convert to an array first; + return Promise.all(this.utils.objectToArray(promises)); + }); + } + + /** + * Sync a feedback only if a certain time has passed since the last time. + * + * @param {number} feedbackId Feedback ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the feedback is synced or if it doesn't need to be synced. + */ + syncFeedbackIfNeeded(feedbackId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.isSyncNeeded(feedbackId, siteId).then((needed) => { + if (needed) { + return this.syncFeedback(feedbackId, siteId); + } + }); + } + + /** + * ynchronize all offline responses of a feedback. + * + * @param {number} feedbackId Feedback ID to be synced. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + syncFeedback(feedbackId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = feedbackId; + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for this feedback, return the promise. + return this.getOngoingSync(syncId, siteId); + } + + // Verify that feedback isn't blocked. + if (this.syncProvider.isBlocked(AddonModFeedbackProvider.COMPONENT, syncId, siteId)) { + this.logger.debug(`Cannot sync feedback '${syncId}' because it is blocked.`); + + return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); + } + + const result = { + warnings: [], + updated: false + }; + + let courseId, + feedback; + + this.logger.debug(`Try to sync feedback '${feedbackId}'`); + + // Get offline responses to be sent. + const syncPromise = this.feedbackOffline.getFeedbackResponses(feedbackId, siteId).catch(() => { + // No offline data found, return empty array. + return []; + }).then((responses) => { + if (!responses.length) { + // Nothing to sync. + return; + } + + if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + courseId = responses[0].courseid; + + return this.feedbackProvider.getFeedbackById(courseId, feedbackId, siteId).then((feedbackData) => { + feedback = feedbackData; + + if (!feedback.multiple_submit) { + // If it does not admit multiple submits, check if it is completed to know if we can submit. + return this.feedbackProvider.isCompleted(feedbackId); + } else { + return false; + } + }).then((isCompleted) => { + if (isCompleted) { + // Cannot submit again, delete resposes. + const promises = []; + + responses.forEach((data) => { + promises.push(this.feedbackOffline.deleteFeedbackPageResponses(feedbackId, data.page, siteId)); + }); + + result.updated = true; + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: feedback.name, + error: this.translate.instant('addon.mod_feedback.this_feedback_is_already_submitted') + })); + + return Promise.all(promises); + } + + return this.feedbackProvider.getCurrentCompletedTimeModified(feedbackId, siteId).then((timemodified) => { + // Sort by page. + responses.sort((a, b) => { + return a.page - b.page; + }); + + responses = responses.map((data) => { + return { + func: this.processPage.bind(this), + params: [feedback, data, siteId, timemodified, result], + blocking: true + }; + }); + + // Execute all the processes in order to solve dependencies. + return this.utils.executeOrderedPromises(responses); + }); + }); + }).then(() => { + if (result.updated) { + // Data has been sent to server. Now invalidate the WS calls. + return this.feedbackProvider.invalidateAllFeedbackData(feedbackId, siteId).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(syncId, siteId); + }).then(() => { + return result; + }); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } + + // Convenience function to sync process page calls. + protected processPage(feedback: any, data: any, siteId: string, timemodified: number, result: any): Promise { + // Delete all pages that are submitted before changing website. + if (timemodified > data.timemodified) { + return this.feedbackOffline.deleteFeedbackPageResponses(feedback.id, data.page, siteId); + } + + return this.feedbackProvider.processPageOnline(feedback.id, data.page, data.responses, false, siteId).then(() => { + result.updated = true; + + return this.feedbackOffline.deleteFeedbackPageResponses(feedback.id, data.page, siteId); + }).catch((error) => { + if (error && error.wserror) { + // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. + result.updated = true; + + return this.feedbackOffline.deleteFeedbackPageResponses(feedback.id, data.page, siteId).then(() => { + // Responses deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: feedback.name, + error: error.error + })); + }); + } else { + // Couldn't connect to server, reject. + return Promise.reject(error && error.error); + } + }); + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 148627057..e929de9c2 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -79,6 +79,7 @@ import { AddonFilesModule } from '@addon/files/files.module'; import { AddonModBookModule } from '@addon/mod/book/book.module'; import { AddonModLabelModule } from '@addon/mod/label/label.module'; import { AddonModResourceModule } from '@addon/mod/resource/resource.module'; +import { AddonModFeedbackModule } from '@addon/mod/feedback/feedback.module'; import { AddonModFolderModule } from '@addon/mod/folder/folder.module'; import { AddonModPageModule } from '@addon/mod/page/page.module'; import { AddonModQuizModule } from '@addon/mod/quiz/quiz.module'; @@ -173,6 +174,7 @@ export const CORE_PROVIDERS: any[] = [ AddonModBookModule, AddonModLabelModule, AddonModResourceModule, + AddonModFeedbackModule, AddonModFolderModule, AddonModPageModule, AddonModQuizModule, diff --git a/src/app/app.scss b/src/app/app.scss index 6a58024c6..0e73a60bb 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -591,6 +591,11 @@ textarea { } } +canvas[core-chart] { + max-width: 500px; + margin: 0 auto; +} + .core-circle:before { content: ' \25CF'; } diff --git a/src/components/tabs/tabs.scss b/src/components/tabs/tabs.scss index a7e779cba..460e33760 100644 --- a/src/components/tabs/tabs.scss +++ b/src/components/tabs/tabs.scss @@ -56,7 +56,7 @@ core-tabs { } } -.scroll-content.no-scroll { +:not(.has-refresher) > .scroll-content.no-scroll { overflow: hidden !important; } diff --git a/src/core/course/classes/main-activity-component.ts b/src/core/course/classes/main-activity-component.ts index ca2d0abd2..523e86f50 100644 --- a/src/core/course/classes/main-activity-component.ts +++ b/src/core/course/classes/main-activity-component.ts @@ -54,7 +54,6 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR this.courseProvider = injector.get(CoreCourseProvider); this.appProvider = injector.get(CoreAppProvider); this.eventsProvider = injector.get(CoreEventsProvider); - this.modulePrefetchProvider = injector.get(CoreCourseModulePrefetchDelegate); const network = injector.get(Network); @@ -78,14 +77,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR if (this.syncEventName) { // Refresh data if this discussion is synchronized automatically. this.syncObserver = this.eventsProvider.on(this.syncEventName, (data) => { - if (this.isRefreshSyncNeeded(data)) { - // Refresh the data. - this.loaded = false; - this.refreshIcon = 'spinner'; - this.syncIcon = 'spinner'; - - this.refreshContent(false); - } + this.autoSyncEventReceived(data); }, this.siteId); } } @@ -100,12 +92,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR */ doRefresh(refresher?: any, done?: () => void, showErrors: boolean = false): Promise { if (this.loaded) { - this.refreshIcon = 'spinner'; - this.syncIcon = 'spinner'; - return this.refreshContent(true, showErrors).finally(() => { - this.refreshIcon = 'refresh'; - this.syncIcon = 'sync'; refresher && refresher.complete(); done && done(); }); @@ -124,6 +111,20 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR return false; } + /** + * An autosync event has been received, check if refresh is needed and update the view. + * + * @param {any} syncEventData Data receiven on sync observer. + */ + protected autoSyncEventReceived(syncEventData: any): void { + if (this.isRefreshSyncNeeded(syncEventData)) { + this.loaded = false; + + // Refresh the data. + this.refreshContent(false); + } + } + /** * Perform the refresh content function. * @@ -132,10 +133,16 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR * @return {Promise} Resolved when done. */ protected refreshContent(sync: boolean = false, showErrors: boolean = false): Promise { + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + return this.invalidateContent().catch(() => { // Ignore errors. }).then(() => { return this.loadContent(true, sync, showErrors); + }).finally(() => { + this.refreshIcon = 'refresh'; + this.syncIcon = 'sync'; }); } diff --git a/src/core/course/classes/main-resource-component.ts b/src/core/course/classes/main-resource-component.ts index 48a093320..2023b1175 100644 --- a/src/core/course/classes/main-resource-component.ts +++ b/src/core/course/classes/main-resource-component.ts @@ -79,10 +79,7 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, */ doRefresh(refresher?: any, done?: () => void): Promise { if (this.loaded) { - this.refreshIcon = 'spinner'; - return this.refreshContent().finally(() => { - this.refreshIcon = 'refresh'; refresher && refresher.complete(); done && done(); }); @@ -97,10 +94,14 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, * @return {Promise} Resolved when done. */ protected refreshContent(): Promise { + this.refreshIcon = 'spinner'; + return this.invalidateContent().catch(() => { // Ignore errors. }).then(() => { return this.loadContent(true); + }).finally(() => { + this.refreshIcon = 'refresh'; }); } diff --git a/src/core/user/providers/user.ts b/src/core/user/providers/user.ts index f9f3c95d0..5aeae8ec9 100644 --- a/src/core/user/providers/user.ts +++ b/src/core/user/providers/user.ts @@ -17,6 +17,7 @@ import { CoreLoggerProvider } from '@providers/logger'; import { CoreSite } from '@classes/site'; import { CoreSitesProvider } from '@providers/sites'; import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreFilepoolProvider } from '@providers/filepool'; /** * Service to provide user functionalities. @@ -53,7 +54,8 @@ export class CoreUserProvider { protected logger; - constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider) { + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, + private filepoolProvider: CoreFilepoolProvider) { this.logger = logger.getInstance('CoreUserProvider'); this.sitesProvider.createTablesFromSchema(this.tablesSchema); } @@ -366,6 +368,36 @@ export class CoreUserProvider { }); } + /** + * Prefetch user profiles and their images from a certain course. It prevents duplicates. + * + * @param {number[]} userIds List of user IDs. + * @param {number} [courseId] Course the users belong to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when prefetched. + */ + prefetchProfiles(userIds: number[], courseId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const treated = {}, + promises = []; + + userIds.forEach((userId) => { + // Prevent repeats and errors. + if (!treated[userId]) { + treated[userId] = true; + + promises.push(this.getProfile(userId, courseId).then((profile) => { + if (profile.profileimageurl) { + this.filepoolProvider.addToQueueByUrl(siteId, profile.profileimageurl); + } + })); + } + }); + + return Promise.all(promises); + } + /** * Store user basic information in local DB to be retrieved if the WS call fails. * diff --git a/src/directives/chart.ts b/src/directives/chart.ts new file mode 100644 index 000000000..c036bef72 --- /dev/null +++ b/src/directives/chart.ts @@ -0,0 +1,142 @@ +// (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 { Directive, Input, OnDestroy, OnInit, ElementRef, OnChanges } from '@angular/core'; +import { Chart } from 'chart.js'; + +/** + * This component shows a chart using chart.js. + * Documentation can be found at http://www.chartjs.org/docs/. + * It does not support changes on any input. + * + * Example usage: + * + */ +@Directive({ + selector: 'canvas[core-chart]' +}) +export class CoreChartDirective implements OnDestroy, OnInit, OnChanges { + // The first 6 colors will be the app colors, the following will be randomly generated. + // It will use the same colors in the whole session. + protected static backgroundColors = [ + 'rgba(0,100,210, 0.6)', + 'rgba(203,61,77, 0.6)', + 'rgba(0,121,130, 0.6)', + 'rgba(249,128,18, 0.6)', + 'rgba(94,129,0, 0.6)', + 'rgba(251,173,26, 0.6)' + ]; + + @Input() data: any[]; // Chart data. + @Input() labels = []; // Labels of the data. + @Input() type: string; // Type of chart. + @Input() legend: any; // Legend options. + + chart: any; + protected element: ElementRef; + + constructor(element: ElementRef) { + this.element = element; + } + + /** + * Component being initialized. + */ + ngOnInit(): any { + let legend = {}; + if (typeof this.legend == 'undefined') { + legend = { + display: true, + position: 'bottom', + labels: { + generateLabels: (chart): any => { + const data = chart.data; + if (data.labels.length && data.labels.length) { + const datasets = data.datasets[0]; + + return data.labels.map((label, i): any => { + return { + text: label + ': ' + datasets.data[i], + fillStyle: datasets.backgroundColor[i] + }; + }); + } + + return []; + } + } + }; + } else { + legend = Object.assign({}, this.legend); + } + + if (this.type == 'bar' && this.data.length >= 5) { + this.type = 'horizontalBar'; + } + + const context = this.element.nativeElement.getContext('2d'); + this.chart = new Chart(context, { + type: this.type, + data: { + labels: this.labels, + datasets: [{ + data: this.data, + backgroundColor: this.getRandomColors(this.data.length) + }] + }, + options: {legend: legend} + }); + } + + /** + * Listen to chart changes. + */ + ngOnChanges(): void { + if (this.chart) { + this.chart.data.datasets[0] = { + data: this.data, + backgroundColor: this.getRandomColors(this.data.length) + }; + this.chart.data.labels = this.labels; + this.chart.update(); + } + } + + /** + * Generate random colors if needed. + * + * @param {number} n Number of colors needed. + * @return {any[]} Array with the number of background colors requested. + */ + protected getRandomColors(n: number): any[] { + while (CoreChartDirective.backgroundColors.length < n) { + const red = Math.floor(Math.random() * 255), + green = Math.floor(Math.random() * 255), + blue = Math.floor(Math.random() * 255); + CoreChartDirective.backgroundColors.push('rgba(' + red + ', ' + green + ', ' + blue + ', 0.6)'); + } + + return CoreChartDirective.backgroundColors.slice(0, n); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): any { + if (this.chart) { + this.chart.destroy(); + this.chart = false; + } + } +} diff --git a/src/directives/directives.module.ts b/src/directives/directives.module.ts index 916710a90..8cf70bd62 100644 --- a/src/directives/directives.module.ts +++ b/src/directives/directives.module.ts @@ -22,6 +22,7 @@ import { CoreKeepKeyboardDirective } from './keep-keyboard'; import { CoreUserLinkDirective } from './user-link'; import { CoreAutoRowsDirective } from './auto-rows'; import { CoreLongPressDirective } from './long-press'; +import { CoreChartDirective } from './chart'; @NgModule({ declarations: [ @@ -33,7 +34,8 @@ import { CoreLongPressDirective } from './long-press'; CoreLinkDirective, CoreUserLinkDirective, CoreAutoRowsDirective, - CoreLongPressDirective + CoreLongPressDirective, + CoreChartDirective ], imports: [], exports: [ @@ -45,7 +47,8 @@ import { CoreLongPressDirective } from './long-press'; CoreLinkDirective, CoreUserLinkDirective, CoreAutoRowsDirective, - CoreLongPressDirective + CoreLongPressDirective, + CoreChartDirective ] }) export class CoreDirectivesModule {} diff --git a/src/providers/lang.ts b/src/providers/lang.ts index 4919e4b36..6207168de 100644 --- a/src/providers/lang.ts +++ b/src/providers/lang.ts @@ -160,7 +160,7 @@ export class CoreLangProvider { return language; }).catch(() => { // User hasn't defined a language. If default language is forced, use it. - if (!CoreConfigConstants.forcedefaultlanguage) { + if (CoreConfigConstants.default_lang && !CoreConfigConstants.forcedefaultlanguage) { return CoreConfigConstants.default_lang; } diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 0a67041e9..5c879c9f5 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -139,6 +139,7 @@ $item-ios-avatar-size: 54px; $loading-ios-spinner-color: $core-color; $spinner-ios-ios-color: $core-color; $tabs-ios-tab-color-inactive: $tabs-tab-color-inactive; +$button-ios-outline-background-color: $white; // App Material Design Variables @@ -152,6 +153,7 @@ $item-md-avatar-size: 54px; $loading-md-spinner-color: $core-color; $spinner-md-crescent-color: $core-color; $tabs-md-tab-color-inactive: $tabs-tab-color-inactive; +$button-md-outline-background-color: $white; // App Windows Variables @@ -164,6 +166,7 @@ $item-wp-avatar-size: 54px; $loading-wp-spinner-color: $core-color; $spinner-wp-circles-color: $core-color; $tabs-wp-tab-color-inactive: $tabs-tab-color-inactive; +$button-wp-outline-background-color: $white; // App Theme