From b58ff46b3c0d859190b695ed5f041b84117054e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 5 Apr 2018 09:37:41 +0200 Subject: [PATCH 1/9] MOBILE-2339 survey: Sync if needed --- src/addon/mod/survey/providers/sync.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/addon/mod/survey/providers/sync.ts b/src/addon/mod/survey/providers/sync.ts index d0717acf8..9e7ae338a 100644 --- a/src/addon/mod/survey/providers/sync.ts +++ b/src/addon/mod/survey/providers/sync.ts @@ -78,7 +78,7 @@ export class AddonModSurveySyncProvider extends CoreSyncBaseProvider { return this.surveyOffline.getAllData(siteId).then((entries) => { // Sync all surveys. const promises = entries.map((entry) => { - return this.syncSurvey(entry.surveyid, entry.userid, siteId).then((result) => { + return this.syncSurveyIfNeeded(entry.surveyid, entry.userid, siteId).then((result) => { if (result && result.answersSent) { // Sync successful, send event. this.eventsProvider.trigger(AddonModSurveySyncProvider.AUTO_SYNCED, { @@ -94,6 +94,26 @@ export class AddonModSurveySyncProvider extends CoreSyncBaseProvider { }); } + /** + * Sync a survey only if a certain time has passed since the last time. + * + * @param {Number} surveyId Survey ID. + * @param {Number} userId User the answers belong to. + * @param {String} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the survey is synced or if it doesn't need to be synced. + */ + syncSurveyIfNeeded(surveyId: number, userId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = this.getSyncId(surveyId, userId); + + return this.isSyncNeeded(syncId, siteId).then((needed) => { + if (needed) { + return this.syncSurvey(surveyId, userId, siteId); + } + }); + } + /** * Synchronize a survey. * From 0da7b03e3d44793db2a249762671494145de2604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 6 Apr 2018 14:37:49 +0200 Subject: [PATCH 2/9] MOBILE-2339 recaptcha: Add captcha component --- src/app/app.scss | 16 ++- src/components/components.module.ts | 11 +- src/components/iframe/iframe.html | 4 +- src/components/iframe/iframe.scss | 21 +++ src/components/iframe/iframe.ts | 5 +- src/components/recaptcha/recaptcha.html | 14 ++ src/components/recaptcha/recaptcha.ts | 127 ++++++++++++++++++ src/components/recaptcha/recaptchamodal.html | 14 ++ src/core/login/lang/en.json | 2 + .../pages/email-signup/email-signup.html | 15 +-- .../login/pages/email-signup/email-signup.ts | 50 +++---- .../providers/user-profile-field-delegate.ts | 4 + src/lang/en.json | 2 + 13 files changed, 234 insertions(+), 51 deletions(-) create mode 100644 src/components/recaptcha/recaptcha.html create mode 100644 src/components/recaptcha/recaptcha.ts create mode 100644 src/components/recaptcha/recaptchamodal.html diff --git a/src/app/app.scss b/src/app/app.scss index 31ac3d32f..6a58024c6 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -603,7 +603,8 @@ textarea { @extend .core-circle:before; color: $color-base; } - .text-#{$color-name} { + + .text-#{$color-name}, p.#{$color-name}, .item p.text-#{$color-name} { color: $color-base; } } @@ -626,4 +627,15 @@ textarea { [ion-fixed] { width: 100%; -} \ No newline at end of file +} + +.core-modal-fullscreen { + .modal-wrapper { + position: absolute; + top: 0 !important; + left: 0 !important; + display: block; + width: 100% !important; + height: 100% !important; + } +} diff --git a/src/components/components.module.ts b/src/components/components.module.ts index 2ede4477b..f1de97468 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -41,6 +41,7 @@ import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; import { CoreDynamicComponent } from './dynamic-component/dynamic-component'; import { CoreSendMessageFormComponent } from './send-message-form/send-message-form'; import { CoreTimerComponent } from './timer/timer'; +import { CoreRecaptchaComponent, CoreRecaptchaModalComponent } from './recaptcha/recaptcha'; @NgModule({ declarations: [ @@ -67,11 +68,14 @@ import { CoreTimerComponent } from './timer/timer'; CoreNavBarButtonsComponent, CoreDynamicComponent, CoreSendMessageFormComponent, - CoreTimerComponent + CoreTimerComponent, + CoreRecaptchaComponent, + CoreRecaptchaModalComponent ], entryComponents: [ CoreContextMenuPopoverComponent, - CoreCoursePickerMenuPopoverComponent + CoreCoursePickerMenuPopoverComponent, + CoreRecaptchaModalComponent ], imports: [ IonicModule, @@ -101,7 +105,8 @@ import { CoreTimerComponent } from './timer/timer'; CoreNavBarButtonsComponent, CoreDynamicComponent, CoreSendMessageFormComponent, - CoreTimerComponent + CoreTimerComponent, + CoreRecaptchaComponent ] }) export class CoreComponentsModule {} diff --git a/src/components/iframe/iframe.html b/src/components/iframe/iframe.html index 69ace4cf8..a94b5d110 100644 --- a/src/components/iframe/iframe.html +++ b/src/components/iframe/iframe.html @@ -1,4 +1,6 @@
- + + +
\ No newline at end of file diff --git a/src/components/iframe/iframe.scss b/src/components/iframe/iframe.scss index 562b80430..8ebb42eb7 100644 --- a/src/components/iframe/iframe.scss +++ b/src/components/iframe/iframe.scss @@ -5,4 +5,25 @@ core-iframe { iframe { border: 0; } + + .core-loading-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: table; + height: 100%; + width: 100%; + z-index: 1; + margin: 0; + padding: 0; + clear: both; + + .core-loading-spinner { + display: table-cell; + text-align: center; + vertical-align: middle; + } + } } diff --git a/src/components/iframe/iframe.ts b/src/components/iframe/iframe.ts index fa61c91fe..67ed9ed94 100644 --- a/src/components/iframe/iframe.ts +++ b/src/components/iframe/iframe.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnInit, ViewChild, ElementRef } from '@angular/core'; +import { Component, Input, Output, OnInit, ViewChild, ElementRef, EventEmitter } from '@angular/core'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { Platform } from 'ionic-angular'; import { CoreFileProvider } from '@providers/file'; @@ -35,6 +35,7 @@ export class CoreIframeComponent implements OnInit { @Input() src: string; @Input() iframeWidth: string; @Input() iframeHeight: string; + @Output() loaded?: EventEmitter = new EventEmitter(); loading: boolean; safeUrl: SafeResourceUrl; @@ -46,6 +47,7 @@ export class CoreIframeComponent implements OnInit { private textUtils: CoreTextUtilsProvider, private utils: CoreUtilsProvider, private domUtils: CoreDomUtilsProvider, private sitesProvider: CoreSitesProvider, private platform: Platform, private sanitizer: DomSanitizer) { this.logger = logger.getInstance('CoreIframe'); + this.loaded = new EventEmitter(); } /** @@ -66,6 +68,7 @@ export class CoreIframeComponent implements OnInit { if (this.loading) { iframe.addEventListener('load', () => { this.loading = false; + this.loaded.emit(iframe); // Notify iframe was loaded. }); iframe.addEventListener('error', () => { diff --git a/src/components/recaptcha/recaptcha.html b/src/components/recaptcha/recaptcha.html new file mode 100644 index 000000000..a0688a12b --- /dev/null +++ b/src/components/recaptcha/recaptcha.html @@ -0,0 +1,14 @@ + +
+ + + +

{{ 'core.answered' | translate }}

+

{{ 'core.login.recaptchaexpired' | translate }}

+
+ + +
+ + {{ 'core.errorloadingcontent' | translate }} +
\ No newline at end of file diff --git a/src/components/recaptcha/recaptcha.ts b/src/components/recaptcha/recaptcha.ts new file mode 100644 index 000000000..d8f2f76af --- /dev/null +++ b/src/components/recaptcha/recaptcha.ts @@ -0,0 +1,127 @@ +// (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 } from '@angular/core'; +import { ModalController, ViewController, NavParams } from 'ionic-angular'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreLangProvider } from '@providers/lang'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; + +/** + * Directive to display a reCaptcha. + * + * Accepts the following attributes: + * @param {any} model The model where to store the recaptcha response. + * @param {string} publicKey The site public key. + * @param {string} [modelValueName] Name of the model property where to store the response. Defaults to 'recaptcharesponse'. + * @param {string} [siteUrl] The site URL. If not defined, current site. + */ +@Component({ + selector: 'core-recaptcha', + templateUrl: 'recaptcha.html' +}) +export class CoreRecaptchaComponent { + expired = false; + + protected lang: string; + + @Input() model: any; + @Input() publicKey: string; + @Input() modelValueName = 'recaptcharesponse'; + @Input() siteUrl?: string; + + constructor(private sitesProvider: CoreSitesProvider, langProvider: CoreLangProvider, + private textUtils: CoreTextUtilsProvider, private modalCtrl: ModalController) { + + // Get the current language of the app. + langProvider.getCurrentLanguage().then((lang) => { + this.lang = lang; + }); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.siteUrl = this.siteUrl || this.sitesProvider.getCurrentSite().getURL(); + } + + /** + * Open the recaptcha modal. + */ + answerRecaptcha(): void { + // Set the iframe src. We use an iframe because reCaptcha V2 doesn't work with file:// protocol. + const src = this.textUtils.concatenatePaths(this.siteUrl, 'webservice/recaptcha.php?lang=' + this.lang); + + // Modal to answer the recaptcha. + // This is because the size of the recaptcha is dynamic, so it could cause problems if it was displayed inline. + const modal = this.modalCtrl.create(CoreRecaptchaModalComponent, { src: src }, + { cssClass: 'core-modal-fullscreen'}); + modal.onDidDismiss((data) => { + this.expired = data.expired; + this.model[this.modelValueName] = data.value; + }); + modal.present(); + } +} + +@Component({ + selector: 'core-recaptcha', + templateUrl: 'recaptchamodal.html' +}) +export class CoreRecaptchaModalComponent { + + expired = false; + value = ''; + src: string; + + constructor(protected viewCtrl: ViewController, params: NavParams) { + this.src = params.get('src'); + } + + /** + * Close modal. + */ + closeModal(): void { + this.viewCtrl.dismiss({ + expired: this.expired, + value: this.value + }); + } + + /** + * The iframe with the recaptcha was loaded. + * + * @param {HTMLIFrameElement} iframe Iframe element. + */ + loaded(iframe: HTMLIFrameElement): void { + // Search the iframe content. + const contentWindow = iframe && iframe.contentWindow; + + if (contentWindow) { + // Set the callbacks we're interested in. + contentWindow['recaptchacallback'] = (value): void => { + this.expired = false; + this.value = value; + this.closeModal(); + }; + + contentWindow['recaptchaexpiredcallback'] = (): void => { + // Verification expired. Check the checkbox again. + this.expired = true; + this.value = ''; + }; + } + } +} diff --git a/src/components/recaptcha/recaptchamodal.html b/src/components/recaptcha/recaptchamodal.html new file mode 100644 index 000000000..8acfd27be --- /dev/null +++ b/src/components/recaptcha/recaptchamodal.html @@ -0,0 +1,14 @@ + + + {{ 'core.login.security_question' | translate }} + + + + + + + + + \ No newline at end of file diff --git a/src/core/login/lang/en.json b/src/core/login/lang/en.json index 60551f7af..faf05a97a 100644 --- a/src/core/login/lang/en.json +++ b/src/core/login/lang/en.json @@ -58,6 +58,8 @@ "profileinvaliddata": "Invalid value", "potentialidps": "Log in using your account on:", "recaptchachallengeimage": "reCAPTCHA challenge image", + "recaptchaexpired": "Verification expired. Answer the security question again.", + "recaptchaincorrect": "The security question answer is incorrect.", "reconnect": "Reconnect", "reconnectdescription": "Your authentication token is invalid or has expired, you have to reconnect to the site.", "reconnectssodescription": "Your authentication token is invalid or has expired, you have to reconnect to the site. You need to log in to the site in a browser window.", diff --git a/src/core/login/pages/email-signup/email-signup.html b/src/core/login/pages/email-signup/email-signup.html index c3fd44236..afef81dc1 100644 --- a/src/core/login/pages/email-signup/email-signup.html +++ b/src/core/login/pages/email-signup/email-signup.html @@ -82,19 +82,10 @@ - + {{ 'core.login.security_question' | translate }} - - {{ 'core.login.recaptchachallengeimage' | translate }} - - {{ 'core.login.enterthewordsabove' | translate }} - - - - - - {{ 'core.login.getanothercaptcha' | translate }} + @@ -113,7 +104,7 @@ - + diff --git a/src/core/login/pages/email-signup/email-signup.ts b/src/core/login/pages/email-signup/email-signup.ts index b713caab5..4bc221699 100644 --- a/src/core/login/pages/email-signup/email-signup.ts +++ b/src/core/login/pages/email-signup/email-signup.ts @@ -44,6 +44,9 @@ export class CoreLoginEmailSignupPage { countriesKeys: any[]; categories: any[]; settingsLoaded = false; + captcha = { + recaptcharesponse: '' + }; // Validation errors. usernameErrors: any; @@ -98,10 +101,6 @@ export class CoreLoginEmailSignupPage { this.signupForm.addControl(this.settings.namefields[i], this.fb.control('', Validators.required)); } - if (this.settings.recaptchachallengehash && this.settings.recaptchachallengeimage) { - this.signupForm.addControl('recaptcharesponse', this.fb.control('', Validators.required)); - } - if (this.settings.sitepolicy) { this.signupForm.addControl('policyagreed', this.fb.control(false, Validators.requiredTrue)); } @@ -133,8 +132,8 @@ export class CoreLoginEmailSignupPage { this.settings = settings; this.categories = this.loginHelper.formatProfileFieldsForSignup(settings.profilefields); - if (this.signupForm && this.signupForm.controls['recaptcharesponse']) { - this.signupForm.controls['recaptcharesponse'].reset(); // Reset captcha. + if (this.settings.recaptchapublickey) { + this.captcha.recaptcharesponse = ''; // Reset captcha. } this.namefieldsErrors = {}; @@ -183,27 +182,11 @@ export class CoreLoginEmailSignupPage { }); } - /** - * Request another captcha. - * - * @param {boolean} ignoreError Whether to ignore errors. - */ - requestCaptcha(ignoreError?: boolean): void { - const modal = this.domUtils.showModalLoading(); - this.getSignupSettings().catch((err) => { - if (!ignoreError && err) { - this.domUtils.showErrorModal(err); - } - }).finally(() => { - modal.dismiss(); - }); - } - /** * Create account. */ create(): void { - if (!this.signupForm.valid) { + if (!this.signupForm.valid || (this.settings.recaptchapublickey && !this.captcha.recaptcharesponse)) { // Form not valid. Scroll to the first element with errors. if (!this.domUtils.scrollToInputError(this.content)) { // Input not found, show an error modal. @@ -226,9 +209,9 @@ export class CoreLoginEmailSignupPage { params.redirect = this.loginHelper.prepareForSSOLogin(this.siteUrl, service, this.siteConfig.launchurl); } - if (this.settings.recaptchachallengehash && this.settings.recaptchachallengeimage) { - params.recaptchachallengehash = this.settings.recaptchachallengehash; - params.recaptcharesponse = this.signupForm.value.recaptcharesponse; + // Get the recaptcha response (if needed). + if (this.settings.recaptchapublickey && this.captcha.recaptcharesponse) { + params.recaptcharesponse = this.captcha.recaptcharesponse; } // Get the data for the custom profile fields. @@ -243,17 +226,20 @@ export class CoreLoginEmailSignupPage { this.domUtils.showAlert(this.translate.instant('core.success'), message); this.navCtrl.pop(); } else { - this.domUtils.showErrorModalFirstWarning(result.warnings, 'core.login.usernotaddederror', true); + if (result.warnings && result.warnings.length) { + let error = result.warnings[0].message; + if (error == 'incorrect-captcha-sol') { + error = this.translate.instant('mm.login.recaptchaincorrect'); + } - // Error sending, request another capctha since the current one is probably invalid now. - this.requestCaptcha(true); + this.domUtils.showErrorModal(error); + } else { + this.domUtils.showErrorModal('core.login.usernotaddederror', true); + } } }); }).catch((error) => { this.domUtils.showErrorModalDefault(error && error.error, 'core.login.usernotaddederror', true); - - // Error sending, request another capctha since the current one is probably invalid now. - this.requestCaptcha(true); }).finally(() => { modal.dismiss(); }); diff --git a/src/core/user/providers/user-profile-field-delegate.ts b/src/core/user/providers/user-profile-field-delegate.ts index 0e48f4a0c..e047100bd 100644 --- a/src/core/user/providers/user-profile-field-delegate.ts +++ b/src/core/user/providers/user-profile-field-delegate.ts @@ -143,6 +143,10 @@ export class CoreUserProfileFieldDelegate extends CoreDelegate { const result = [], promises = []; + if (!fields) { + return Promise.resolve([]); + } + fields.forEach((field) => { promises.push(this.getDataForField(field, signup, registerAuth, formValues).then((data) => { if (data) { diff --git a/src/lang/en.json b/src/lang/en.json index cf901b5b4..2a0e60e40 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -3,6 +3,8 @@ "add": "Add", "allparticipants": "All participants", "android": "Android", + "answer": "Answer", + "answered": "Answered", "areyousure": "Are you sure?", "back": "Back", "cancel": "Cancel", 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 3/9] 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 From 02deb0f078efecfe68497bec400e48a6ca2645f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 22 Mar 2018 12:43:05 +0100 Subject: [PATCH 4/9] MOBILE-2339 feedback: Add Respondents and non respondents page --- src/addon/mod/feedback/lang/en.json | 8 + .../pages/nonrespondents/nonrespondents.html | 45 ++++ .../nonrespondents/nonrespondents.module.ts | 35 +++ .../pages/nonrespondents/nonrespondents.ts | 159 ++++++++++++++ .../pages/respondents/respondents.html | 53 +++++ .../pages/respondents/respondents.module.ts | 37 ++++ .../feedback/pages/respondents/respondents.ts | 202 ++++++++++++++++++ src/addon/mod/feedback/providers/feedback.ts | 118 ++++++++++ src/addon/mod/feedback/providers/helper.ts | 61 ++++++ 9 files changed, 718 insertions(+) create mode 100644 src/addon/mod/feedback/pages/nonrespondents/nonrespondents.html create mode 100644 src/addon/mod/feedback/pages/nonrespondents/nonrespondents.module.ts create mode 100644 src/addon/mod/feedback/pages/nonrespondents/nonrespondents.ts create mode 100644 src/addon/mod/feedback/pages/respondents/respondents.html create mode 100644 src/addon/mod/feedback/pages/respondents/respondents.module.ts create mode 100644 src/addon/mod/feedback/pages/respondents/respondents.ts diff --git a/src/addon/mod/feedback/lang/en.json b/src/addon/mod/feedback/lang/en.json index 113036f44..f9a3b44a2 100644 --- a/src/addon/mod/feedback/lang/en.json +++ b/src/addon/mod/feedback/lang/en.json @@ -1,18 +1,26 @@ { "analysis": "Analysis", "anonymous": "Anonymous", + "anonymous_entries": "Anonymous entries ({{$a}})", "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", + "feedback_is_not_open": "The feedback is not open", "mode": "Mode", "non_anonymous": "User's name will be logged and shown with answers", + "non_anonymous_entries": "Non anonymous entries ({{$a}})", + "non_respondents_students": "Non respondents students ({{$a}})", + "not_started": "Not started", "overview": "Overview", "page_after_submit": "Completion message", "preview": "Preview", "questions": "Questions", + "responses": "Responses", + "response_nr": "Response number", "show_nonrespondents": "Show non-respondents", + "started": "Started", "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/nonrespondents/nonrespondents.html b/src/addon/mod/feedback/pages/nonrespondents/nonrespondents.html new file mode 100644 index 000000000..906dfbd6b --- /dev/null +++ b/src/addon/mod/feedback/pages/nonrespondents/nonrespondents.html @@ -0,0 +1,45 @@ + + + {{ 'addon.mod_feedback.responses' |translate }} + + + + + + + + + + {{ 'core.groupsseparate' | translate }} + {{ 'core.groupsvisible' | translate }} + + {{groupOpt.name}} + + + + {{ 'addon.mod_feedback.non_respondents_students' | translate : {$a: total } }} + + + + + + +

+

+ + {{ 'addon.mod_feedback.started' | translate}} + + + {{ 'addon.mod_feedback.not_started' | translate}} + +

+
+
+ + + + + +
+
+
diff --git a/src/addon/mod/feedback/pages/nonrespondents/nonrespondents.module.ts b/src/addon/mod/feedback/pages/nonrespondents/nonrespondents.module.ts new file mode 100644 index 000000000..2197062a0 --- /dev/null +++ b/src/addon/mod/feedback/pages/nonrespondents/nonrespondents.module.ts @@ -0,0 +1,35 @@ +// (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 { CoreComponentsModule } from '@components/components.module'; +import { AddonModFeedbackComponentsModule } from '../../components/components.module'; +import { AddonModFeedbackNonRespondentsPage } from './nonrespondents'; + +@NgModule({ + declarations: [ + AddonModFeedbackNonRespondentsPage, + ], + imports: [ + CoreDirectivesModule, + CoreComponentsModule, + AddonModFeedbackComponentsModule, + IonicPageModule.forChild(AddonModFeedbackNonRespondentsPage), + TranslateModule.forChild() + ], +}) +export class AddonModFeedbackNonRespondentsPageModule {} diff --git a/src/addon/mod/feedback/pages/nonrespondents/nonrespondents.ts b/src/addon/mod/feedback/pages/nonrespondents/nonrespondents.ts new file mode 100644 index 000000000..1ca3de974 --- /dev/null +++ b/src/addon/mod/feedback/pages/nonrespondents/nonrespondents.ts @@ -0,0 +1,159 @@ +// (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 } from '@angular/core'; +import { IonicPage, NavParams, NavController } from 'ionic-angular'; +import { AddonModFeedbackProvider } from '../../providers/feedback'; +import { AddonModFeedbackHelperProvider } from '../../providers/helper'; +import { CoreGroupInfo, CoreGroupsProvider } from '@providers/groups'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; + +/** + * Page that displays feedback non respondents. + */ +@IonicPage({ segment: 'addon-mod-feedback-nonrespondents' }) +@Component({ + selector: 'page-addon-mod-feedback-nonrespondents', + templateUrl: 'nonrespondents.html', +}) +export class AddonModFeedbackNonRespondentsPage { + + protected moduleId: number; + protected feedbackId: number; + protected courseId: number; + protected page = 0; + + selectedGroup: number; + groupInfo: CoreGroupInfo = { + groups: [], + separateGroups: false, + visibleGroups: false + }; + + users = []; + total = 0; + canLoadMore = false; + + feedbackLoaded = false; + loadingMore = false; + + constructor(navParams: NavParams, protected feedbackProvider: AddonModFeedbackProvider, + protected groupsProvider: CoreGroupsProvider, protected domUtils: CoreDomUtilsProvider, + protected feedbackHelper: AddonModFeedbackHelperProvider, protected navCtrl: NavController) { + const module = navParams.get('module'); + this.moduleId = module.id; + this.feedbackId = module.instance; + this.courseId = navParams.get('courseId'); + this.selectedGroup = navParams.get('group') || 0; + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + this.fetchData(); + } + + /** + * Fetch all the data required for the view. + * + * @param {boolean} [refresh] Empty events array first. + * @return {Promise} Promise resolved when done. + */ + fetchData(refresh: boolean = false): Promise { + this.page = 0; + this.total = 0; + this.users = []; + + return this.groupsProvider.getActivityGroupInfo(this.moduleId).then((groupInfo) => { + this.groupInfo = groupInfo; + + return this.loadGroupUsers(this.selectedGroup); + }).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); + + if (!refresh) { + // Some call failed on first fetch, go back. + this.navCtrl.pop(); + } + + return Promise.reject(null); + }); + } + + /** + * Load Group responses. + * + * @param {number} [groupId] If defined it will change group if not, it will load more users for the same group. + * @return {Promise} Resolved with the attempts loaded. + */ + protected loadGroupUsers(groupId?: number): Promise { + if (typeof groupId == 'undefined') { + this.page++; + this.loadingMore = true; + } else { + this.selectedGroup = groupId; + this.page = 0; + this.total = 0; + this.users = []; + this.feedbackLoaded = false; + } + + return this.feedbackHelper.getNonRespondents(this.feedbackId, this.selectedGroup, this.page).then((response) => { + this.total = response.total; + + if (this.users.length < response.total) { + this.users = this.users.concat(response.users); + } + + this.canLoadMore = this.users.length < response.total; + + return response; + }).finally(() => { + this.loadingMore = false; + this.feedbackLoaded = true; + }); + } + + /** + * Change selected group or load more users. + * + * @param {number} [groupId] Group ID selected. If not defined, it will load more users. + */ + loadAttempts(groupId?: number): void { + this.loadGroupUsers(groupId).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); + }); + } + + /** + * Refresh the attempts. + * + * @param {any} refresher Refresher. + */ + refreshFeedback(refresher: any): void { + if (this.feedbackLoaded) { + const promises = []; + + promises.push(this.feedbackProvider.invalidateNonRespondentsData(this.feedbackId)); + promises.push(this.groupsProvider.invalidateActivityGroupInfo(this.moduleId)); + + Promise.all(promises).finally(() => { + return this.fetchData(true); + }).finally(() => { + refresher.complete(); + }); + } + } +} diff --git a/src/addon/mod/feedback/pages/respondents/respondents.html b/src/addon/mod/feedback/pages/respondents/respondents.html new file mode 100644 index 000000000..f9e2c2dd5 --- /dev/null +++ b/src/addon/mod/feedback/pages/respondents/respondents.html @@ -0,0 +1,53 @@ + + + {{ 'addon.mod_feedback.responses' |translate }} + + + + + + + + + + + {{ 'core.groupsseparate' | translate }} + {{ 'core.groupsvisible' | translate }} + + {{groupOpt.name}} + + + + + {{ 'addon.mod_feedback.non_anonymous_entries' | translate : {$a: responses.total } }} + + + + + +

+

{{attempt.timemodified * 1000 | coreFormatDate: "LLL"}}

+
+ + + + + +
+ + + {{ 'addon.mod_feedback.anonymous_entries' |translate : {$a: anonResponses.total } }} + + +

{{ 'addon.mod_feedback.response_nr' |translate }}: {{attempt.number}}

+
+ + + + + +
+
+
+
+
diff --git a/src/addon/mod/feedback/pages/respondents/respondents.module.ts b/src/addon/mod/feedback/pages/respondents/respondents.module.ts new file mode 100644 index 000000000..d81210084 --- /dev/null +++ b/src/addon/mod/feedback/pages/respondents/respondents.module.ts @@ -0,0 +1,37 @@ +// (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 { CoreComponentsModule } from '@components/components.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { AddonModFeedbackComponentsModule } from '../../components/components.module'; +import { AddonModFeedbackRespondentsPage } from './respondents'; + +@NgModule({ + declarations: [ + AddonModFeedbackRespondentsPage, + ], + imports: [ + CoreDirectivesModule, + CoreComponentsModule, + CorePipesModule, + AddonModFeedbackComponentsModule, + IonicPageModule.forChild(AddonModFeedbackRespondentsPage), + TranslateModule.forChild() + ], +}) +export class AddonModFeedbackRespondentsPageModule {} diff --git a/src/addon/mod/feedback/pages/respondents/respondents.ts b/src/addon/mod/feedback/pages/respondents/respondents.ts new file mode 100644 index 000000000..f281c5374 --- /dev/null +++ b/src/addon/mod/feedback/pages/respondents/respondents.ts @@ -0,0 +1,202 @@ +// (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, NavController } from 'ionic-angular'; +import { AddonModFeedbackProvider } from '../../providers/feedback'; +import { AddonModFeedbackHelperProvider } from '../../providers/helper'; +import { CoreGroupInfo, CoreGroupsProvider } from '@providers/groups'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; + +/** + * Page that displays feedback respondents. + */ +@IonicPage({ segment: 'addon-mod-feedback-respondents' }) +@Component({ + selector: 'page-addon-mod-feedback-respondents', + templateUrl: 'respondents.html', +}) +export class AddonModFeedbackRespondentsPage { + @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + + protected moduleId: number; + protected feedbackId: number; + protected courseId: number; + protected page = 0; + + selectedGroup: number; + groupInfo: CoreGroupInfo = { + groups: [], + separateGroups: false, + visibleGroups: false + }; + + responses = { + attempts: [], + total: 0, + canLoadMore: false + }; + anonResponses = { + attempts: [], + total: 0, + canLoadMore: false + }; + feedbackLoaded = false; + loadingMore = false; + attemptId: number; + + constructor(navParams: NavParams, protected feedbackProvider: AddonModFeedbackProvider, + protected groupsProvider: CoreGroupsProvider, protected domUtils: CoreDomUtilsProvider, + protected feedbackHelper: AddonModFeedbackHelperProvider, protected navCtrl: NavController) { + const module = navParams.get('module'); + this.moduleId = module.id; + this.feedbackId = module.instance; + this.courseId = navParams.get('courseId'); + this.selectedGroup = navParams.get('group') || 0; + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + this.fetchData().then(() => { + if (this.splitviewCtrl.isOn()) { + if (this.responses.attempts.length > 0) { + // Take first and load it. + this.gotoAttempt(this.responses.attempts[0]); + } else if (this.anonResponses.attempts.length > 0) { + // Take first and load it. + this.gotoAttempt(this.anonResponses.attempts[0]); + } + } + }); + } + + /** + * Fetch all the data required for the view. + * + * @param {boolean} [refresh] Empty events array first. + * @return {Promise} Promise resolved when done. + */ + fetchData(refresh: boolean = false): Promise { + this.page = 0; + this.responses.total = 0; + this.responses.attempts = []; + this.anonResponses.total = 0; + this.anonResponses.attempts = []; + + return this.groupsProvider.getActivityGroupInfo(this.moduleId).then((groupInfo) => { + this.groupInfo = groupInfo; + + return this.loadGroupAttempts(this.selectedGroup); + }).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); + + if (!refresh) { + // Some call failed on first fetch, go back. + this.navCtrl.pop(); + } + + return Promise.reject(null); + }); + } + + /** + * Load Group attempts. + * + * @param {number} [groupId] If defined it will change group if not, it will load more attempts for the same group. + * @return {Promise} Resolved with the attempts loaded. + */ + protected loadGroupAttempts(groupId?: number): Promise { + if (typeof groupId == 'undefined') { + this.page++; + this.loadingMore = true; + } else { + this.selectedGroup = groupId; + this.page = 0; + this.responses.total = 0; + this.responses.attempts = []; + this.anonResponses.total = 0; + this.anonResponses.attempts = []; + this.feedbackLoaded = false; + } + + return this.feedbackHelper.getResponsesAnalysis(this.feedbackId, this.selectedGroup, this.page).then((responses) => { + this.responses.total = responses.totalattempts; + this.anonResponses.total = responses.totalanonattempts; + + if (this.anonResponses.attempts.length < responses.totalanonattempts) { + this.anonResponses.attempts = this.anonResponses.attempts.concat(responses.anonattempts); + } + if (this.responses.attempts.length < responses.totalattempts) { + this.responses.attempts = this.responses.attempts.concat(responses.attempts); + } + + this.anonResponses.canLoadMore = this.anonResponses.attempts.length < responses.totalanonattempts; + this.responses.canLoadMore = this.responses.attempts.length < responses.totalattempts; + + return responses; + }).finally(() => { + this.loadingMore = false; + this.feedbackLoaded = true; + }); + } + + /** + * Navigate to a particular attempt. + * + * @param {any} attempt Attempt object to load. + */ + gotoAttempt(attempt: any): void { + this.attemptId = attempt.id; + this.splitviewCtrl.push('AddonModFeedbackAttemptPage', { + attemptid: attempt.id, + attempt: attempt, + feedbackId: this.feedbackId, + moduleId: this.moduleId + }); + } + + /** + * Change selected group or load more attempts. + * + * @param {number} [groupId] Group ID selected. If not defined, it will load more attempts. + */ + loadAttempts(groupId?: number): void { + this.loadGroupAttempts(groupId).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); + }); + } + + /** + * Refresh the attempts. + * + * @param {any} refresher Refresher. + */ + refreshFeedback(refresher: any): void { + if (this.feedbackLoaded) { + const promises = []; + + promises.push(this.feedbackProvider.invalidateResponsesAnalysisData(this.feedbackId)); + promises.push(this.groupsProvider.invalidateActivityGroupInfo(this.moduleId)); + + Promise.all(promises).finally(() => { + return this.fetchData(true); + }).finally(() => { + refresher.complete(); + }); + } + } +} diff --git a/src/addon/mod/feedback/providers/feedback.ts b/src/addon/mod/feedback/providers/feedback.ts index e954024d8..7c960b4e5 100644 --- a/src/addon/mod/feedback/providers/feedback.ts +++ b/src/addon/mod/feedback/providers/feedback.ts @@ -298,6 +298,96 @@ export class AddonModFeedbackProvider { return this.getFeedbackDataByKey(courseId, 'id', id, siteId, forceCache); } + /** + * Retrieves a list of students who didn't submit the feedback. + * + * @param {number} feedbackId Feedback ID. + * @param {number} [groupId=0] Group id, 0 means that the function will determine the user group. + * @param {number} [page=0] The page of records to return. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the info is retrieved. + */ + getNonRespondents(feedbackId: number, groupId: number = 0, page: number = 0, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + feedbackid: feedbackId, + groupid: groupId, + page: page + }, + preSets = { + cacheKey: this.getNonRespondentsDataCacheKey(feedbackId, groupId) + }; + + return site.read('mod_feedback_get_non_respondents', params, preSets); + }); + } + + /** + * Get cache key for non respondents feedback data WS calls. + * + * @param {number} feedbackId Feedback ID. + * @param {number} [groupId=0] Group id, 0 means that the function will determine the user group. + * @return {string} Cache key. + */ + protected getNonRespondentsDataCacheKey(feedbackId: number, groupId: number = 0): string { + return this.getNonRespondentsDataPrefixCacheKey(feedbackId) + groupId; + } + + /** + * Get prefix cache key for feedback non respondents data WS calls. + * + * @param {number} feedbackId Feedback ID. + * @return {string} Cache key. + */ + protected getNonRespondentsDataPrefixCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':nonrespondents:'; + } + + /** + * Returns the feedback user responses. + * + * @param {number} feedbackId Feedback ID. + * @param {number} groupId Group id, 0 means that the function will determine the user group. + * @param {number} page The page of records to return. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the info is retrieved. + */ + getResponsesAnalysis(feedbackId: number, groupId: number, page: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + feedbackid: feedbackId, + groupid: groupId || 0, + page: page || 0 + }, + preSets = { + cacheKey: this.getResponsesAnalysisDataCacheKey(feedbackId, groupId) + }; + + return site.read('mod_feedback_get_responses_analysis', params, preSets); + }); + } + + /** + * Get cache key for responses analysis feedback data WS calls. + * + * @param {number} feedbackId Feedback ID. + * @param {number} [groupId=0] Group id, 0 means that the function will determine the user group. + * @return {string} Cache key. + */ + protected getResponsesAnalysisDataCacheKey(feedbackId: number, groupId: number = 0): string { + return this.getResponsesAnalysisDataPrefixCacheKey(feedbackId) + groupId; + } + + /** + * Get prefix cache key for feedback responses analysis data WS calls. + * + * @param {number} feedbackId Feedback ID. + * @return {string} Cache key. + */ + protected getResponsesAnalysisDataPrefixCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':responsesanalysis:'; + } + /** * Gets the resume page information. * @@ -449,6 +539,34 @@ export class AddonModFeedbackProvider { return this.filepoolProvider.invalidateFilesByComponent(siteId, AddonModFeedbackProvider.COMPONENT, moduleId); } + /** + * Invalidates feedback non respondents 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. + */ + invalidateNonRespondentsData(feedbackId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getNonRespondentsDataPrefixCacheKey(feedbackId)); + + }); + } + + /** + * Invalidates feedback user responses 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. + */ + invalidateResponsesAnalysisData(feedbackId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getResponsesAnalysisDataPrefixCacheKey(feedbackId)); + + }); + } + /** * Invalidates launch feedback data. * diff --git a/src/addon/mod/feedback/providers/helper.ts b/src/addon/mod/feedback/providers/helper.ts index 7d83e1fd1..81a04a919 100644 --- a/src/addon/mod/feedback/providers/helper.ts +++ b/src/addon/mod/feedback/providers/helper.ts @@ -14,6 +14,8 @@ import { Injectable } from '@angular/core'; import { NavController } from 'ionic-angular'; +import { AddonModFeedbackProvider } from './feedback'; +import { CoreUserProvider } from '@core/user/providers/user'; /** * Service that provides helper functions for feedbacks. @@ -21,6 +23,9 @@ import { NavController } from 'ionic-angular'; @Injectable() export class AddonModFeedbackHelperProvider { + constructor(protected feedbackProvider: AddonModFeedbackProvider, protected userProvider: CoreUserProvider) { + } + /** * 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. * @@ -63,6 +68,62 @@ export class AddonModFeedbackHelperProvider { return 0; } + /** + * Retrieves a list of students who didn't submit the feedback with extra info. + * + * @param {number} feedbackId Feedback ID. + * @param {number} groupId Group id, 0 means that the function will determine the user group. + * @param {number} page The page of records to return. + * @return {Promise} Promise resolved when the info is retrieved. + */ + getNonRespondents(feedbackId: number, groupId: number, page: number): Promise { + return this.feedbackProvider.getNonRespondents(feedbackId, groupId, page).then((responses) => { + return this.addImageProfileToAttempts(responses.users).then((users) => { + responses.users = users; + + return responses; + }); + }); + } + + /** + * Returns the feedback user responses with extra info. + * + * @param {number} feedbackId Feedback ID. + * @param {number} groupId Group id, 0 means that the function will determine the user group. + * @param {number} page The page of records to return. + * @return {Promise} Promise resolved when the info is retrieved. + */ + getResponsesAnalysis(feedbackId: number, groupId: number, page: number): Promise { + return this.feedbackProvider.getResponsesAnalysis(feedbackId, groupId, page).then((responses) => { + return this.addImageProfileToAttempts(responses.attempts).then((attempts) => { + responses.attempts = attempts; + + return responses; + }); + }); + } + + /** + * Add Image profile url field on attempts + * + * @param {any} attempts Attempts array to get profile from. + * @return {Promise} Returns the same array with the profileimageurl added if found. + */ + protected addImageProfileToAttempts(attempts: any): Promise { + const promises = attempts.map((attempt) => { + return this.userProvider.getProfile(attempt.userid, attempt.courseid, true).then((user) => { + attempt.profileimageurl = user.profileimageurl; + }).catch(() => { + // Error getting profile, resolve promise without adding any extra data. + }); + }); + + return Promise.all(promises).then(() => { + return attempts; + }); + } + /** * Helper function to open a feature in the app. * From 50aacb573408f2a1130acff79978756f95f272a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 22 Mar 2018 14:45:10 +0100 Subject: [PATCH 5/9] MOBILE-2339 feedback: Add responses link handlers --- .../mod/feedback/components/index/index.html | 2 +- .../mod/feedback/components/index/index.ts | 4 + src/addon/mod/feedback/feedback.module.ts | 14 ++- .../feedback/pages/respondents/respondents.ts | 2 +- .../providers/analysis-link-handler.ts | 93 +++++++++++++++ src/addon/mod/feedback/providers/feedback.ts | 58 ++++++++++ .../providers/show-entries-link-handler.ts | 109 ++++++++++++++++++ .../show-non-respondents-link-handler.ts | 93 +++++++++++++++ 8 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 src/addon/mod/feedback/providers/analysis-link-handler.ts create mode 100644 src/addon/mod/feedback/providers/show-entries-link-handler.ts create mode 100644 src/addon/mod/feedback/providers/show-non-respondents-link-handler.ts diff --git a/src/addon/mod/feedback/components/index/index.html b/src/addon/mod/feedback/components/index/index.html index 1cb45dff4..12040b63d 100644 --- a/src/addon/mod/feedback/components/index/index.html +++ b/src/addon/mod/feedback/components/index/index.html @@ -16,7 +16,7 @@ - + diff --git a/src/addon/mod/feedback/components/index/index.ts b/src/addon/mod/feedback/components/index/index.ts index 8fa071427..10dd875b8 100644 --- a/src/addon/mod/feedback/components/index/index.ts +++ b/src/addon/mod/feedback/components/index/index.ts @@ -61,6 +61,7 @@ export class AddonModFeedbackIndexComponent extends CoreCourseModuleMainActivity }; showTabs = false; tabsReady = false; + firstSelectedTab: number; protected submitObserver: any; @@ -165,7 +166,10 @@ export class AddonModFeedbackIndexComponent extends CoreCourseModuleMainActivity this.access = accessData; this.showTabs = (accessData.canviewreports || accessData.canviewanalysis) && !accessData.isempty; + this.firstSelectedTab = 0; if (this.tab == 'analysis') { + this.firstSelectedTab = 1; + return this.fetchFeedbackAnalysisData(this.access); } diff --git a/src/addon/mod/feedback/feedback.module.ts b/src/addon/mod/feedback/feedback.module.ts index 2c412f694..3a1f67923 100644 --- a/src/addon/mod/feedback/feedback.module.ts +++ b/src/addon/mod/feedback/feedback.module.ts @@ -21,6 +21,9 @@ import { AddonModFeedbackComponentsModule } from './components/components.module import { AddonModFeedbackModuleHandler } from './providers/module-handler'; import { AddonModFeedbackProvider } from './providers/feedback'; import { AddonModFeedbackLinkHandler } from './providers/link-handler'; +import { AddonModFeedbackAnalysisLinkHandler } from './providers/analysis-link-handler'; +import { AddonModFeedbackShowEntriesLinkHandler } from './providers/show-entries-link-handler'; +import { AddonModFeedbackShowNonRespondentsLinkHandler } from './providers/show-non-respondents-link-handler'; import { AddonModFeedbackHelperProvider } from './providers/helper'; import { AddonModFeedbackPrefetchHandler } from './providers/prefetch-handler'; import { AddonModFeedbackSyncProvider } from './providers/sync'; @@ -39,6 +42,9 @@ import { AddonModFeedbackOfflineProvider } from './providers/offline'; AddonModFeedbackPrefetchHandler, AddonModFeedbackHelperProvider, AddonModFeedbackLinkHandler, + AddonModFeedbackAnalysisLinkHandler, + AddonModFeedbackShowEntriesLinkHandler, + AddonModFeedbackShowNonRespondentsLinkHandler, AddonModFeedbackSyncCronHandler, AddonModFeedbackSyncProvider, AddonModFeedbackOfflineProvider @@ -48,10 +54,16 @@ export class AddonModFeedbackModule { constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModFeedbackModuleHandler, prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModFeedbackPrefetchHandler, contentLinksDelegate: CoreContentLinksDelegate, linkHandler: AddonModFeedbackLinkHandler, - cronDelegate: CoreCronDelegate, syncHandler: AddonModFeedbackSyncCronHandler) { + cronDelegate: CoreCronDelegate, syncHandler: AddonModFeedbackSyncCronHandler, + analysisLinkHandler: AddonModFeedbackAnalysisLinkHandler, + showEntriesLinkHandler: AddonModFeedbackShowEntriesLinkHandler, + showNonRespondentsLinkHandler: AddonModFeedbackShowNonRespondentsLinkHandler) { moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); contentLinksDelegate.registerHandler(linkHandler); + contentLinksDelegate.registerHandler(analysisLinkHandler); + contentLinksDelegate.registerHandler(showEntriesLinkHandler); + contentLinksDelegate.registerHandler(showNonRespondentsLinkHandler); cronDelegate.register(syncHandler); } } diff --git a/src/addon/mod/feedback/pages/respondents/respondents.ts b/src/addon/mod/feedback/pages/respondents/respondents.ts index f281c5374..2f3fb82b0 100644 --- a/src/addon/mod/feedback/pages/respondents/respondents.ts +++ b/src/addon/mod/feedback/pages/respondents/respondents.ts @@ -162,7 +162,7 @@ export class AddonModFeedbackRespondentsPage { gotoAttempt(attempt: any): void { this.attemptId = attempt.id; this.splitviewCtrl.push('AddonModFeedbackAttemptPage', { - attemptid: attempt.id, + attemptId: attempt.id, attempt: attempt, feedbackId: this.feedbackId, moduleId: this.moduleId diff --git a/src/addon/mod/feedback/providers/analysis-link-handler.ts b/src/addon/mod/feedback/providers/analysis-link-handler.ts new file mode 100644 index 000000000..12f03a387 --- /dev/null +++ b/src/addon/mod/feedback/providers/analysis-link-handler.ts @@ -0,0 +1,93 @@ +// (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 { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { AddonModFeedbackProvider } from './feedback'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; + +/** + * Content links handler for a feedback analysis. + * Match mod/feedback/analysis.php with a valid feedback id. + */ +@Injectable() +export class AddonModFeedbackAnalysisLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonModFeedbackAnalysisLinkHandler'; + featureName = '$mmCourseDelegate_mmaModFeedback'; + pattern = /\/mod\/feedback\/analysis\.php.*([\&\?]id=\d+)/; + + constructor(private linkHelper: CoreContentLinksHelperProvider, private feedbackProvider: AddonModFeedbackProvider, + private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + return [{ + action: (siteId, navCtrl?): void => { + const modal = this.domUtils.showModalLoading(), + moduleId = params.id; + + this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((module) => { + const stateParams = { + module: module, + courseId: module.course, + tab: 'analysis' + }; + + return this.linkHelper.goInSite(navCtrl, 'AddonModFeedbackIndexPage', stateParams, siteId); + }).finally(() => { + modal.dismiss(); + }); + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + return this.feedbackProvider.isPluginEnabled(siteId).then((enabled) => { + if (!enabled) { + return false; + } + + if (typeof params.id == 'undefined') { + // Cannot treat the URL. + return false; + } + + return true; + }); + } +} diff --git a/src/addon/mod/feedback/providers/feedback.ts b/src/addon/mod/feedback/providers/feedback.ts index 7c960b4e5..bc85041c1 100644 --- a/src/addon/mod/feedback/providers/feedback.ts +++ b/src/addon/mod/feedback/providers/feedback.ts @@ -80,6 +80,64 @@ export class AddonModFeedbackProvider { return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':analysis:'; } + /** + * Find an attempt in all responses analysis. + * + * @param {number} feedbackId Feedback ID. + * @param {number} attemptId Attempt id to find. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {any} [previous] Only for recurrent use. Object with the previous fetched info. + * @return {Promise} Promise resolved when the info is retrieved. + */ + getAttempt(feedbackId: number, attemptId: number, siteId?: string, previous?: any): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + if (typeof previous == 'undefined') { + previous = { + page: 0, + attemptsLoaded: 0, + anonAttemptsLoaded: 0 + }; + } + + return this.getResponsesAnalysis(feedbackId, 0, previous.page, siteId).then((responses) => { + let attempt; + + attempt = responses.attempts.find((attempt) => { + return attemptId == attempt.id; + }); + + if (attempt) { + return attempt; + } + + attempt = responses.anonattempts.find((attempt) => { + return attemptId == attempt.id; + }); + + if (attempt) { + return attempt; + } + + if (previous.anonAttemptsLoaded < responses.totalanonattempts) { + previous.anonAttemptsLoaded += responses.anonattempts.length; + } + + if (previous.attemptsLoaded < responses.totalattempts) { + previous.attemptsLoaded += responses.attempts.length; + } + + if (previous.anonAttemptsLoaded < responses.totalanonattempts || previous.attemptsLoaded < responses.totalattempts) { + // Can load more. Check there. + previous.page++; + + return this.getAttempt(feedbackId, attemptId, siteId, previous); + } + + // Not found and all loaded. Reject. + return Promise.reject(null); + }); + } + /** * Get prefix cache key for feedback completion data WS calls. * diff --git a/src/addon/mod/feedback/providers/show-entries-link-handler.ts b/src/addon/mod/feedback/providers/show-entries-link-handler.ts new file mode 100644 index 000000000..74e61ff5e --- /dev/null +++ b/src/addon/mod/feedback/providers/show-entries-link-handler.ts @@ -0,0 +1,109 @@ +// (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 { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { AddonModFeedbackProvider } from './feedback'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; + +/** + * Content links handler for feedback show entries questions. + * Match mod/feedback/show_entries.php with a valid feedback id. + */ +@Injectable() +export class AddonModFeedbackShowEntriesLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonModFeedbackShowEntriesLinkHandler'; + featureName = '$mmCourseDelegate_mmaModFeedback'; + pattern = /\/mod\/feedback\/show_entries\.php.*([\?\&](id|showcompleted)=\d+)/; + + constructor(private linkHelper: CoreContentLinksHelperProvider, private feedbackProvider: AddonModFeedbackProvider, + private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + return [{ + action: (siteId, navCtrl?): void => { + const modal = this.domUtils.showModalLoading(), + moduleId = params.id; + + this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((module) => { + let stateParams; + + if (typeof params.showcompleted == 'undefined') { + // Param showcompleted not defined. Show entry list. + stateParams = { + moduleId: module.id, + module: module, + courseId: module.course + }; + + return this.linkHelper.goInSite(navCtrl, 'AddonModFeedbackRespondentsPage', stateParams, siteId); + } + + return this.feedbackProvider.getAttempt(module.instance, params.showcompleted, siteId).then((attempt) => { + stateParams = { + moduleId: module.id, + attempt: attempt, + attemptId: attempt.id, + feedbackId: module.instance + }; + + return this.linkHelper.goInSite(navCtrl, 'AddonModFeedbackAttemptPage', stateParams, siteId); + }); + }).finally(() => { + modal.dismiss(); + }); + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + return this.feedbackProvider.isPluginEnabled(siteId).then((enabled) => { + if (!enabled) { + return false; + } + + if (typeof params.id == 'undefined') { + // Cannot treat the URL. + return false; + } + + return true; + }); + } +} diff --git a/src/addon/mod/feedback/providers/show-non-respondents-link-handler.ts b/src/addon/mod/feedback/providers/show-non-respondents-link-handler.ts new file mode 100644 index 000000000..5d1ab4cb3 --- /dev/null +++ b/src/addon/mod/feedback/providers/show-non-respondents-link-handler.ts @@ -0,0 +1,93 @@ +// (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 { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { AddonModFeedbackProvider } from './feedback'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; + +/** + * Content links handler for feedback show non respondents. + * Match mod/feedback/show_nonrespondents.php with a valid feedback id. + */ +@Injectable() +export class AddonModFeedbackShowNonRespondentsLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonModFeedbackShowNonRespondentsLinkHandler'; + featureName = '$mmCourseDelegate_mmaModFeedback'; + pattern = /\/mod\/feedback\/show_nonrespondents\.php.*([\?\&](id)=\d+)/; + + constructor(private linkHelper: CoreContentLinksHelperProvider, private feedbackProvider: AddonModFeedbackProvider, + private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + return [{ + action: (siteId, navCtrl?): void => { + const modal = this.domUtils.showModalLoading(), + moduleId = params.id; + + this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((module) => { + const stateParams = { + module: module, + moduleId: module.id, + courseId: module.course + }; + + return this.linkHelper.goInSite(navCtrl, 'AddonModFeedbackNonRespondentsPage', stateParams, siteId); + }).finally(() => { + modal.dismiss(); + }); + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + return this.feedbackProvider.isPluginEnabled(siteId).then((enabled) => { + if (!enabled) { + return false; + } + + if (typeof params.id == 'undefined') { + // Cannot treat the URL. + return false; + } + + return true; + }); + } +} From fca428843e05ec62b3def5cb94e8f04fd57a9008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 22 Mar 2018 16:49:13 +0100 Subject: [PATCH 6/9] MOBILE-2339 feedback: Add Attempt review page --- src/addon/mod/feedback/lang/en.json | 1 + .../mod/feedback/pages/attempt/attempt.html | 34 +++ .../feedback/pages/attempt/attempt.module.ts | 37 +++ .../mod/feedback/pages/attempt/attempt.ts | 89 ++++++++ src/addon/mod/feedback/providers/feedback.ts | 35 +++ src/addon/mod/feedback/providers/helper.ts | 212 +++++++++++++++++- 6 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 src/addon/mod/feedback/pages/attempt/attempt.html create mode 100644 src/addon/mod/feedback/pages/attempt/attempt.module.ts create mode 100644 src/addon/mod/feedback/pages/attempt/attempt.ts diff --git a/src/addon/mod/feedback/lang/en.json b/src/addon/mod/feedback/lang/en.json index f9a3b44a2..1e4de3090 100644 --- a/src/addon/mod/feedback/lang/en.json +++ b/src/addon/mod/feedback/lang/en.json @@ -13,6 +13,7 @@ "non_anonymous": "User's name will be logged and shown with answers", "non_anonymous_entries": "Non anonymous entries ({{$a}})", "non_respondents_students": "Non respondents students ({{$a}})", + "not_selected": "Not selected", "not_started": "Not started", "overview": "Overview", "page_after_submit": "Completion message", diff --git a/src/addon/mod/feedback/pages/attempt/attempt.html b/src/addon/mod/feedback/pages/attempt/attempt.html new file mode 100644 index 000000000..f3e10d3ff --- /dev/null +++ b/src/addon/mod/feedback/pages/attempt/attempt.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + +

{{attempt.fullname}}

+

{{attempt.timemodified * 1000 | coreFormatDate:"LLL"}}

+
+ + +

{{ 'addon.mod_feedback.response_nr' |translate }}: {{attempt.number}} ({{ 'addon.mod_feedback.anonymous' |translate }})

+

{{attempt.timemodified * 1000 | coreFormatDate:"LLL"}}

+
+ + + + +

+ {{item.itemnumber}}. {{ item.name }} +

+

+
+
+
+
+
+
diff --git a/src/addon/mod/feedback/pages/attempt/attempt.module.ts b/src/addon/mod/feedback/pages/attempt/attempt.module.ts new file mode 100644 index 000000000..df162f477 --- /dev/null +++ b/src/addon/mod/feedback/pages/attempt/attempt.module.ts @@ -0,0 +1,37 @@ +// (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 { CoreComponentsModule } from '@components/components.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { AddonModFeedbackComponentsModule } from '../../components/components.module'; +import { AddonModFeedbackAttemptPage } from './attempt'; + +@NgModule({ + declarations: [ + AddonModFeedbackAttemptPage, + ], + imports: [ + CoreDirectivesModule, + CoreComponentsModule, + CorePipesModule, + AddonModFeedbackComponentsModule, + IonicPageModule.forChild(AddonModFeedbackAttemptPage), + TranslateModule.forChild() + ], +}) +export class AddonModFeedbackAttemptPageModule {} diff --git a/src/addon/mod/feedback/pages/attempt/attempt.ts b/src/addon/mod/feedback/pages/attempt/attempt.ts new file mode 100644 index 000000000..f53f04ead --- /dev/null +++ b/src/addon/mod/feedback/pages/attempt/attempt.ts @@ -0,0 +1,89 @@ +// (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 } from '@angular/core'; +import { IonicPage, NavParams, NavController } from 'ionic-angular'; +import { AddonModFeedbackProvider } from '../../providers/feedback'; +import { AddonModFeedbackHelperProvider } from '../../providers/helper'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; + +/** + * Page that displays a feedback attempt review. + */ +@IonicPage({ segment: 'addon-mod-feedback-attempt' }) +@Component({ + selector: 'page-addon-mod-feedback-attempt', + templateUrl: 'attempt.html', +}) +export class AddonModFeedbackAttemptPage { + + protected feedbackId: number; + + attempt: any; + items: any; + componentId: number; + component = AddonModFeedbackProvider.COMPONENT; + feedbackLoaded = false; + + constructor(navParams: NavParams, protected feedbackProvider: AddonModFeedbackProvider, protected navCtrl: NavController, + protected domUtils: CoreDomUtilsProvider, protected feedbackHelper: AddonModFeedbackHelperProvider, + protected textUtils: CoreTextUtilsProvider) { + this.feedbackId = navParams.get('feedbackId') || 0; + this.attempt = navParams.get('attempt') || false; + this.componentId = navParams.get('moduleId'); + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + this.fetchData(); + } + + /** + * Fetch all the data required for the view. + * + * @return {Promise} Promise resolved when done. + */ + fetchData(): Promise { + return this.feedbackProvider.getItems(this.feedbackId).then((items) => { + // Add responses and format items. + this.items = items.items.map((item) => { + if (item.typ == 'label') { + item.submittedValue = this.textUtils.replacePluginfileUrls(item.presentation, item.itemfiles); + } else { + for (const x in this.attempt.responses) { + if (this.attempt.responses[x].id == item.id) { + item.submittedValue = this.attempt.responses[x].printval; + delete this.attempt.responses[x]; + break; + } + } + } + + return this.feedbackHelper.getItemForm(item, true); + }); + + }).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); + // Some call failed on first fetch, go back. + this.navCtrl.pop(); + + return Promise.reject(null); + }).finally(() => { + this.feedbackLoaded = true; + }); + } +} diff --git a/src/addon/mod/feedback/providers/feedback.ts b/src/addon/mod/feedback/providers/feedback.ts index bc85041c1..8a358a9b5 100644 --- a/src/addon/mod/feedback/providers/feedback.ts +++ b/src/addon/mod/feedback/providers/feedback.ts @@ -25,6 +25,11 @@ import { CoreFilepoolProvider } from '@providers/filepool'; export class AddonModFeedbackProvider { static COMPONENT = 'mmaModFeedback'; static FORM_SUBMITTED = 'addon_mod_feedback_form_submitted'; + static LINE_SEP = '|'; + static MULTICHOICE_TYPE_SEP = '>>>>>'; + static MULTICHOICE_ADJUST_SEP = '<<<<<'; + static MULTICHOICE_HIDENOSELECT = 'h'; + static MULTICHOICERATED_VALUE_SEP = '####'; protected ROOT_CACHE_KEY = this.ROOT_CACHE_KEY + ''; protected logger; @@ -356,6 +361,36 @@ export class AddonModFeedbackProvider { return this.getFeedbackDataByKey(courseId, 'id', id, siteId, forceCache); } + /** + * Returns the items (questions) in the given feedback. + * + * @param {number} feedbackId Feedback ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the info is retrieved. + */ + getItems(feedbackId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + feedbackid: feedbackId + }, + preSets = { + cacheKey: this.getItemsDataCacheKey(feedbackId) + }; + + return site.read('mod_feedback_get_items', params, preSets); + }); + } + + /** + * Get cache key for get items feedback data WS calls. + * + * @param {number} feedbackId Feedback ID. + * @return {string} Cache key. + */ + protected getItemsDataCacheKey(feedbackId: number): string { + return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':items'; + } + /** * Retrieves a list of students who didn't submit the feedback. * diff --git a/src/addon/mod/feedback/providers/helper.ts b/src/addon/mod/feedback/providers/helper.ts index 81a04a919..a6c9b48c3 100644 --- a/src/addon/mod/feedback/providers/helper.ts +++ b/src/addon/mod/feedback/providers/helper.ts @@ -16,6 +16,9 @@ import { Injectable } from '@angular/core'; import { NavController } from 'ionic-angular'; import { AddonModFeedbackProvider } from './feedback'; import { CoreUserProvider } from '@core/user/providers/user'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { TranslateService } from '@ngx-translate/core'; +import * as moment from 'moment'; /** * Service that provides helper functions for feedbacks. @@ -23,7 +26,12 @@ import { CoreUserProvider } from '@core/user/providers/user'; @Injectable() export class AddonModFeedbackHelperProvider { - constructor(protected feedbackProvider: AddonModFeedbackProvider, protected userProvider: CoreUserProvider) { + protected MODE_RESPONSETIME = 1; + protected MODE_COURSE = 2; + protected MODE_CATEGORY = 3; + + constructor(protected feedbackProvider: AddonModFeedbackProvider, protected userProvider: CoreUserProvider, + protected textUtils: CoreTextUtilsProvider, protected translate: TranslateService) { } /** @@ -161,4 +169,206 @@ export class AddonModFeedbackHelperProvider { return navCtrl.push(pageName, stateParams); } + /** + * Helper funtion for item type Label. + * + * @param {any} item Item to process. + * @return {any} Item processed to show form. + */ + protected getItemFormLabel(item: any): any { + item.template = 'label'; + item.name = ''; + item.presentation = this.textUtils.replacePluginfileUrls(item.presentation, item.itemfiles); + + return item; + } + + /** + * Helper funtion for item type Info. + * + * @param {any} item Item to process. + * @return {any} Item processed to show form. + */ + protected getItemFormInfo(item: any): any { + item.template = 'label'; + + const type = parseInt(item.presentation, 10); + + if (type == this.MODE_COURSE || type == this.MODE_CATEGORY) { + item.presentation = item.otherdata; + item.value = typeof item.rawValue != 'undefined' ? item.rawValue : item.otherdata; + } else if (type == this.MODE_RESPONSETIME) { + item.value = '__CURRENT__TIMESTAMP__'; + const tempValue = typeof item.rawValue != 'undefined' ? item.rawValue * 1000 : new Date().getTime(); + item.presentation = moment(tempValue).format('LLL'); + } else { + // Errors on item, return false. + return false; + } + + return item; + } + + /** + * Helper funtion for item type Numeric. + * + * @param {any} item Item to process. + * @return {any} Item processed to show form. + */ + protected getItemFormNumeric(item: any): any { + item.template = 'numeric'; + + const range = item.presentation.split(AddonModFeedbackProvider.LINE_SEP) || []; + item.rangefrom = range.length > 0 ? parseInt(range[0], 10) || '' : ''; + item.rangeto = range.length > 1 ? parseInt(range[1], 10) || '' : ''; + item.value = typeof item.rawValue != 'undefined' ? parseFloat(item.rawValue) : ''; + + return item; + } + + /** + * Helper funtion for item type Text field. + * + * @param {any} item Item to process. + * @return {any} Item processed to show form. + */ + protected getItemFormTextfield(item: any): any { + item.template = 'textfield'; + item.length = item.presentation.split(AddonModFeedbackProvider.LINE_SEP)[1] || 255; + item.value = typeof item.rawValue != 'undefined' ? item.rawValue : ''; + + return item; + } + + /** + * Helper funtion for item type Textarea. + * + * @param {any} item Item to process. + * @return {any} Item processed to show form. + */ + protected getItemFormTextarea(item: any): any { + item.template = 'textarea'; + item.value = typeof item.rawValue != 'undefined' ? item.rawValue : ''; + + return item; + } + + /** + * Helper funtion for item type Multichoice. + * + * @param {any} item Item to process. + * @return {any} Item processed to show form. + */ + protected getItemFormMultichoice(item: any): any { + let parts = item.presentation.split(AddonModFeedbackProvider.MULTICHOICE_TYPE_SEP) || []; + item.subtype = parts.length > 0 && parts[0] ? parts[0] : 'r'; + item.template = 'multichoice-' + item.subtype; + + item.presentation = parts.length > 1 ? parts[1] : ''; + if (item.subtype != 'd') { + parts = item.presentation.split(AddonModFeedbackProvider.MULTICHOICE_ADJUST_SEP) || []; + item.presentation = parts.length > 0 ? parts[0] : ''; + // Horizontal are not supported right now. item.horizontal = parts.length > 1 && !!parts[1]; + } else { + item.class = 'item-select'; + } + + item.choices = item.presentation.split(AddonModFeedbackProvider.LINE_SEP) || []; + item.choices = item.choices.map((choice, index) => { + const weightValue = choice.split(AddonModFeedbackProvider.MULTICHOICERATED_VALUE_SEP) || ['']; + choice = weightValue.length == 1 ? weightValue[0] : '(' + weightValue[0] + ') ' + weightValue[1]; + + return {value: index + 1, label: choice}; + }); + + if (item.subtype === 'r' && item.options.search(AddonModFeedbackProvider.MULTICHOICE_HIDENOSELECT) == -1) { + item.choices.unshift({value: 0, label: this.translate.instant('addon.mod_feedback.not_selected')}); + item.value = typeof item.rawValue != 'undefined' ? parseInt(item.rawValue, 10) : 0; + } else if (item.subtype === 'd') { + item.choices.unshift({value: 0, label: ''}); + item.value = typeof item.rawValue != 'undefined' ? parseInt(item.rawValue, 10) : 0; + } else if (item.subtype === 'c') { + if (typeof item.rawValue == 'undefined') { + item.value = ''; + } else { + item.rawValue = '' + item.rawValue; + const values = item.rawValue.split(AddonModFeedbackProvider.LINE_SEP); + item.choices.forEach((choice) => { + for (const x in values) { + if (choice.value == values[x]) { + choice.checked = true; + + return; + } + } + }); + } + } else { + item.value = typeof item.rawValue != 'undefined' ? parseInt(item.rawValue, 10) : ''; + } + + return item; + } + + /** + * Helper funtion for item type Captcha. + * + * @param {any} item Item to process. + * @return {any} Item processed to show form. + */ + protected getItemFormCaptcha(item: any): any { + const data = this.textUtils.parseJSON(item.otherdata); + if (data && data.length > 3) { + item.captcha = { + challengehash: data[0], + imageurl: data[1], + jsurl: data[2], + recaptchapublickey: data[3] + }; + } + item.template = 'captcha'; + item.value = ''; + + return item; + } + + /** + * Process and returns item to print form. + * + * @param {any} item Item to process. + * @param {boolean} preview Previewing options. + * @return {any} Item processed to show form. + */ + getItemForm(item: any, preview: boolean): any { + switch (item.typ) { + case 'label': + return this.getItemFormLabel(item); + case 'info': + return this.getItemFormInfo(item); + case 'numeric': + return this.getItemFormNumeric(item); + case 'textfield': + return this.getItemFormTextfield(item); + case 'textarea': + return this.getItemFormTextarea(item); + case 'multichoice': + return this.getItemFormMultichoice(item); + case 'multichoicerated': + return this.getItemFormMultichoice(item); + case 'pagebreak': + if (!preview) { + // Pagebreaks are only used on preview. + return false; + } + break; + case 'captcha': + // Captcha is not supported right now. However label will be shown. + return this.getItemFormCaptcha(item); + default: + return false; + } + + return item; + } + } From d8d34a786a717de43f6c7672d1734767d33aa512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 23 Mar 2018 08:38:00 +0100 Subject: [PATCH 7/9] MOBILE-2339 feedback: Implement form page --- .../mod/feedback/components/index/index.ts | 6 +- src/addon/mod/feedback/lang/en.json | 8 + src/addon/mod/feedback/pages/form/form.html | 116 +++++ .../mod/feedback/pages/form/form.module.ts | 37 ++ src/addon/mod/feedback/pages/form/form.scss | 15 + src/addon/mod/feedback/pages/form/form.ts | 341 +++++++++++++ src/addon/mod/feedback/providers/feedback.ts | 448 +++++++++++++++++- src/addon/mod/feedback/providers/helper.ts | 87 +++- src/addon/mod/feedback/providers/offline.ts | 2 +- .../feedback/providers/prefetch-handler.ts | 8 +- src/app/app.scss | 28 +- .../course/classes/main-activity-component.ts | 2 +- src/providers/utils/utils.ts | 3 +- 13 files changed, 1074 insertions(+), 27 deletions(-) create mode 100644 src/addon/mod/feedback/pages/form/form.html create mode 100644 src/addon/mod/feedback/pages/form/form.module.ts create mode 100644 src/addon/mod/feedback/pages/form/form.scss create mode 100644 src/addon/mod/feedback/pages/form/form.ts diff --git a/src/addon/mod/feedback/components/index/index.ts b/src/addon/mod/feedback/components/index/index.ts index 10dd875b8..6e31b5a9f 100644 --- a/src/addon/mod/feedback/components/index/index.ts +++ b/src/addon/mod/feedback/components/index/index.ts @@ -338,11 +338,11 @@ export class AddonModFeedbackIndexComponent extends CoreCourseModuleMainActivity * * @param {boolean} preview Preview or edit the form. */ - gotoAnswerQuestions(preview: boolean): void { + gotoAnswerQuestions(preview: boolean = false): void { const stateParams = { module: this.module, - moduleid: this.module.id, - courseid: this.courseId, + moduleId: this.module.id, + courseId: this.courseId, preview: preview }; this.navCtrl.push('AddonModFeedbackFormPage', stateParams); diff --git a/src/addon/mod/feedback/lang/en.json b/src/addon/mod/feedback/lang/en.json index 1e4de3090..49e281d75 100644 --- a/src/addon/mod/feedback/lang/en.json +++ b/src/addon/mod/feedback/lang/en.json @@ -3,24 +3,32 @@ "anonymous": "Anonymous", "anonymous_entries": "Anonymous entries ({{$a}})", "average": "Average", + "captchaofflinewarning": "Feedback with CAPTCHA cannot be completed offline, or if not configured, or if the server is down.", "completed_feedbacks": "Submitted answers", "complete_the_form": "Answer the questions...", "continue_the_form": "Continue the form", "feedbackclose": "Allow answers to", "feedbackopen": "Allow answers from", "feedback_is_not_open": "The feedback is not open", + "feedback_submitted_offline": "This feedback has been saved to be submitted later.", + "mapcourses": "Map feedback to courses", "mode": "Mode", + "next_page": "Next page", "non_anonymous": "User's name will be logged and shown with answers", "non_anonymous_entries": "Non anonymous entries ({{$a}})", "non_respondents_students": "Non respondents students ({{$a}})", "not_selected": "Not selected", "not_started": "Not started", + "numberoutofrange": "Number out of range", "overview": "Overview", "page_after_submit": "Completion message", "preview": "Preview", + "previous_page": "Previous page", "questions": "Questions", "responses": "Responses", "response_nr": "Response number", + "save_entries": "Submit your answers", + "show_entries": "Show responses", "show_nonrespondents": "Show non-respondents", "started": "Started", "this_feedback_is_already_submitted": "You've already completed this activity." diff --git a/src/addon/mod/feedback/pages/form/form.html b/src/addon/mod/feedback/pages/form/form.html new file mode 100644 index 000000000..22a9b8689 --- /dev/null +++ b/src/addon/mod/feedback/pages/form/form.html @@ -0,0 +1,116 @@ + + + + + + + + + + +

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

+

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

+

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

+
+ + + + + {{item.itemnumber}}. {{ item.name }} + +
+ + +

+
+ + + + + +

{{ 'addon.mod_feedback.numberoutofrange' |translate }} [{{item.rangefrom}}, {{item.rangeto}}]

+
+ + + + + + + {{option.label}} + + + + + + + {{option.label}} + + + + + + {{option.label}} + + + + +
+ + {{ 'addon.mod_feedback.captchaofflinewarning' | translate }} +
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+ +
+ +

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

+

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

+

+
+ + + + + + + + + + + + + +
+
diff --git a/src/addon/mod/feedback/pages/form/form.module.ts b/src/addon/mod/feedback/pages/form/form.module.ts new file mode 100644 index 000000000..854f15034 --- /dev/null +++ b/src/addon/mod/feedback/pages/form/form.module.ts @@ -0,0 +1,37 @@ +// (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 { CoreComponentsModule } from '@components/components.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { AddonModFeedbackComponentsModule } from '../../components/components.module'; +import { AddonModFeedbackFormPage } from './form'; + +@NgModule({ + declarations: [ + AddonModFeedbackFormPage, + ], + imports: [ + CoreDirectivesModule, + CoreComponentsModule, + CorePipesModule, + AddonModFeedbackComponentsModule, + IonicPageModule.forChild(AddonModFeedbackFormPage), + TranslateModule.forChild() + ], +}) +export class AddonModFeedbackFormPageModule {} diff --git a/src/addon/mod/feedback/pages/form/form.scss b/src/addon/mod/feedback/pages/form/form.scss new file mode 100644 index 000000000..ec55664a2 --- /dev/null +++ b/src/addon/mod/feedback/pages/form/form.scss @@ -0,0 +1,15 @@ +page-addon-mod-feedback-form { + .addon-mod_feedback-form-content { + align-self: self-start; + width: 100%; + } + .item-md .addon-mod_feedback-form-content { + @include margin($item-md-padding-media-top, ($item-md-padding-end / 2), $item-md-padding-media-bottom, 0); + } + .item-ios .addon-mod_feedback-form-content { + @include margin($item-ios-padding-media-top, $item-ios-padding-start, $item-ios-padding-media-bottom, 0); + } + .item-wp .addon-mod_feedback-form-content { + @include margin($item-wp-padding-media-top, ($item-wp-padding-end / 2), $item-wp-padding-media-bottom, 0); + } +} \ No newline at end of file diff --git a/src/addon/mod/feedback/pages/form/form.ts b/src/addon/mod/feedback/pages/form/form.ts new file mode 100644 index 000000000..a1641c98c --- /dev/null +++ b/src/addon/mod/feedback/pages/form/form.ts @@ -0,0 +1,341 @@ +// (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, OnDestroy, Optional } from '@angular/core'; +import { IonicPage, NavParams, NavController, Content } from 'ionic-angular'; +import { Network } from '@ionic-native/network'; +import { TranslateService } from '@ngx-translate/core'; +import { AddonModFeedbackProvider } from '../../providers/feedback'; +import { AddonModFeedbackHelperProvider } from '../../providers/helper'; +import { AddonModFeedbackSyncProvider } from '../../providers/sync'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreSitesProvider } from '@providers/sites'; + +/** + * Page that displays feedback form. + */ +@IonicPage({ segment: 'addon-mod-feedback-form' }) +@Component({ + selector: 'page-addon-mod-feedback-form', + templateUrl: 'form.html', +}) +export class AddonModFeedbackFormPage implements OnDestroy { + + protected module: any; + protected currentPage: number; + protected submitted: any; + protected feedback; + protected siteAfterSubmit; + protected onlineObserver; + protected originalData; + protected currentSite; + protected forceLeave = false; + + title: string; + preview = false; + courseId: number; + componentId: number; + completionPageContents: string; + component = AddonModFeedbackProvider.COMPONENT; + offline = false; + feedbackLoaded = false; + access: any; + items = []; + hasPrevPage = false; + hasNextPage = false; + completed = false; + completedOffline = false; + + constructor(navParams: NavParams, protected feedbackProvider: AddonModFeedbackProvider, protected appProvider: CoreAppProvider, + protected utils: CoreUtilsProvider, protected domUtils: CoreDomUtilsProvider, protected navCtrl: NavController, + protected feedbackHelper: AddonModFeedbackHelperProvider, protected courseProvider: CoreCourseProvider, + protected eventsProvider: CoreEventsProvider, protected feedbackSync: AddonModFeedbackSyncProvider, network: Network, + protected translate: TranslateService, protected loginHelper: CoreLoginHelperProvider, + protected linkHelper: CoreContentLinksHelperProvider, sitesProvider: CoreSitesProvider, + @Optional() private content: Content) { + + this.module = navParams.get('module'); + this.courseId = navParams.get('courseId'); + this.currentPage = navParams.get('page'); + this.title = navParams.get('title'); + this.preview = !!navParams.get('preview'); + this.componentId = navParams.get('moduleId') || this.module.id; + + this.currentSite = sitesProvider.getCurrentSite(); + + // Refresh online status when changes. + this.onlineObserver = network.onchange().subscribe((online) => { + this.offline = !online; + }); + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + this.fetchData().then(() => { + this.feedbackProvider.logView(this.feedback.id, true).then(() => { + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + }); + }); + } + + /** + * View entered. + */ + ionViewDidEnter(): void { + this.forceLeave = false; + } + + /** + * Check if we can leave the page or not. + * + * @return {boolean | Promise} Resolved if we can leave it, rejected if not. + */ + ionViewCanLeave(): boolean | Promise { + if (this.forceLeave) { + return true; + } + + if (!this.preview) { + const responses = this.feedbackHelper.getPageItemsResponses(this.items); + + if (this.items && !this.completed && this.originalData) { + // Form submitted. Check if there is any change. + if (!this.utils.basicLeftCompare(responses, this.originalData, 3)) { + return this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit')); + } + } + } + + return Promise.resolve(); + } + + /** + * Fetch all the data required for the view. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchData(): Promise { + this.offline = !this.appProvider.isOnline(); + + return this.feedbackProvider.getFeedback(this.courseId, this.module.id).then((feedbackData) => { + this.feedback = feedbackData; + + this.title = this.feedback.name || this.title; + + return this.fetchAccessData(); + }).then((accessData) => { + if (!this.preview && accessData.cansubmit && !accessData.isempty) { + return typeof this.currentPage == 'undefined' ? + this.feedbackProvider.getResumePage(this.feedback.id, this.offline, true) : + Promise.resolve(this.currentPage); + } else { + this.preview = true; + + return Promise.resolve(0); + } + }).catch((error) => { + if (!this.offline && !this.utils.isWebServiceError(error)) { + // If it fails, go offline. + this.offline = true; + + return this.feedbackProvider.getResumePage(this.feedback.id, true); + } + + return Promise.reject(error); + }).then((page) => { + return this.fetchFeedbackPageData(page || 0); + }).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); + this.forceLeave = true; + this.navCtrl.pop(); + + return Promise.reject(null); + }).finally(() => { + this.feedbackLoaded = true; + }); + } + + /** + * Fetch access information. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchAccessData(): Promise { + return this.feedbackProvider.getFeedbackAccessInformation(this.feedback.id, this.offline, true).catch((error) => { + if (!this.offline && !this.utils.isWebServiceError(error)) { + // If it fails, go offline. + this.offline = true; + + return this.feedbackProvider.getFeedbackAccessInformation(this.feedback.id, true); + } + + return Promise.reject(error); + }).then((accessData) => { + this.access = accessData; + + return accessData; + }); + } + + protected fetchFeedbackPageData(page: number = 0): Promise { + let promise; + this.items = []; + + if (this.preview) { + promise = this.feedbackProvider.getItems(this.feedback.id); + } else { + this.currentPage = page; + + promise = this.feedbackProvider.getPageItemsWithValues(this.feedback.id, page, this.offline, true).catch((error) => { + if (!this.offline && !this.utils.isWebServiceError(error)) { + // If it fails, go offline. + this.offline = true; + + return this.feedbackProvider.getPageItemsWithValues(this.feedback.id, page, true); + } + + return Promise.reject(error); + }).then((response) => { + this.hasPrevPage = !!response.hasprevpage; + this.hasNextPage = !!response.hasnextpage; + + return response; + }); + } + + return promise.then((response) => { + this.items = response.items.map((itemData) => { + return this.feedbackHelper.getItemForm(itemData, this.preview); + }).filter((itemData) => { + // Filter items with errors. + return itemData; + }); + + if (!this.preview) { + const itemsCopy = this.utils.clone(this.items); // Copy the array to avoid modifications. + this.originalData = this.feedbackHelper.getPageItemsResponses(itemsCopy); + } + }); + } + + /** + * Function to allow page navigation through the questions form. + * + * @param {boolean} goPrevious If true it will go back to the previous page, if false, it will go forward. + * @return {Promise} Resolved when done. + */ + gotoPage(goPrevious: boolean): Promise { + this.content && this.content.scrollToTop(); + this.feedbackLoaded = false; + + const responses = this.feedbackHelper.getPageItemsResponses(this.items), + formHasErrors = this.items.some((item) => { + return item.isEmpty || item.hasError; + }); + + // Sync other pages first. + return this.feedbackSync.syncFeedback(this.feedback.id).catch(() => { + // Ignore errors. + }).then(() => { + return this.feedbackProvider.processPage(this.feedback.id, this.currentPage, responses, goPrevious, formHasErrors, + this.courseId).then((response) => { + const jumpTo = parseInt(response.jumpto, 10); + + if (response.completed) { + // Form is completed, show completion message and buttons. + this.items = []; + this.completed = true; + this.completedOffline = !!response.offline; + this.completionPageContents = response.completionpagecontents; + this.siteAfterSubmit = response.siteaftersubmit; + this.submitted = true; + + // Invalidate access information so user will see home page updated (continue form or completion messages). + const promises = []; + promises.push(this.feedbackProvider.invalidateFeedbackAccessInformationData(this.feedback.id)); + promises.push(this.feedbackProvider.invalidateResumePageData(this.feedback.id)); + + return Promise.all(promises).then(() => { + return this.fetchAccessData(); + }); + } else if (isNaN(jumpTo) || jumpTo == this.currentPage) { + // Errors on questions, stay in page. + return Promise.resolve(); + } else { + this.submitted = true; + // Invalidate access information so user will see home page updated (continue form). + this.feedbackProvider.invalidateResumePageData(this.feedback.id); + + // Fetch the new page. + return this.fetchFeedbackPageData(jumpTo); + } + }); + }).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); + + return Promise.reject(null); + }).finally(() => { + this.feedbackLoaded = true; + }); + } + + /** + * Function to link implemented features. + */ + showAnalysis(): void { + this.submitted = 'analysis'; + this.feedbackHelper.openFeature('analysis', this.navCtrl, this.module, this.courseId); + } + + /** + * Function to go to the page after submit. + */ + continue(): void { + if (this.siteAfterSubmit) { + const modal = this.domUtils.showModalLoading(); + this.linkHelper.handleLink(this.siteAfterSubmit).then((treated) => { + if (!treated) { + return this.currentSite.openInBrowserWithAutoLoginIfSameSite(this.siteAfterSubmit); + } + }).finally(() => { + modal.dismiss(); + }); + } else { + // Use redirect to make the course the new history root (to avoid "loops" in history). + this.loginHelper.redirect('CoreCourseSectionPage', { + course: { id: this.courseId } + }, this.currentSite.getId()); + } + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + if (this.submitted) { + const tab = this.submitted == 'analysis' ? 'analysis' : 'overview'; + // If form has been submitted, the info has been already invalidated but we should update index view. + this.eventsProvider.trigger(AddonModFeedbackProvider.FORM_SUBMITTED, {feedbackId: this.feedback.id, tab: tab}); + } + this.onlineObserver && this.onlineObserver.unsubscribe(); + } +} diff --git a/src/addon/mod/feedback/providers/feedback.ts b/src/addon/mod/feedback/providers/feedback.ts index 8a358a9b5..3b5fa4291 100644 --- a/src/addon/mod/feedback/providers/feedback.ts +++ b/src/addon/mod/feedback/providers/feedback.ts @@ -17,6 +17,8 @@ import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreFilepoolProvider } from '@providers/filepool'; +import { CoreAppProvider } from '@providers/app'; +import { AddonModFeedbackOfflineProvider } from './offline'; /** * Service that provides some features for feedbacks. @@ -35,10 +37,255 @@ export class AddonModFeedbackProvider { protected logger; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, - private filepoolProvider: CoreFilepoolProvider) { + private filepoolProvider: CoreFilepoolProvider, private feedbackOffline: AddonModFeedbackOfflineProvider, + private appProvider: CoreAppProvider) { this.logger = logger.getInstance('AddonModFeedbackProvider'); } + /** + * Check dependency of a question item. + * + * @param {any[]} items All question items to check dependency. + * @param {any} item Item to check. + * @return {boolean} Return true if dependency is acomplished and it can be shown. False, otherwise. + */ + protected checkDependencyItem(items: any[], item: any): boolean { + const depend = items.find((itemFind) => { + return itemFind.id == item.dependitem; + }); + + // Item not found, looks like dependent item has been removed or is in the same or following pages. + if (!depend) { + return true; + } + + switch (depend.typ) { + case 'label': + return false; + case 'multichoice': + case 'multichoicerated': + return this.compareDependItemMultichoice(depend, item.dependvalue); + default: + break; + } + + return item.dependvalue == depend.rawValue; + } + + /** + * Check dependency item of type Multichoice. + * + * @param {any} item Item to check. + * @param {string} dependValue Value to compare. + * @return {boolean} eturn true if dependency is acomplished and it can be shown. False, otherwise. + */ + protected compareDependItemMultichoice(item: any, dependValue: string): boolean { + let values, choices; + const parts = item.presentation.split(AddonModFeedbackProvider.MULTICHOICE_TYPE_SEP) || [], + subtype = parts.length > 0 && parts[0] ? parts[0] : 'r'; + + choices = parts[1] || ''; + choices = choices.split(AddonModFeedbackProvider.MULTICHOICE_ADJUST_SEP)[0] || ''; + choices = choices.split(AddonModFeedbackProvider.LINE_SEP) || []; + + if (subtype === 'c') { + if (typeof item.rawValue == 'undefined') { + values = ['']; + } else { + item.rawValue = '' + item.rawValue; + values = item.rawValue.split(AddonModFeedbackProvider.LINE_SEP); + } + } else { + values = [item.rawValue]; + } + + for (let index = 0; index < choices.length; index++) { + for (const x in values) { + if (values[x] == index + 1) { + let value = choices[index]; + + if (item.typ == 'multichoicerated') { + value = value.split(AddonModFeedbackProvider.MULTICHOICERATED_VALUE_SEP)[1] || ''; + } + + if (value.trim() == dependValue) { + return true; + } + + // We can finish checking if only searching on one value and we found it. + if (values.length == 1) { + return false; + } + } + } + } + + return false; + } + + /** + * Fill values of item questions. + * + * @param {number} feedbackId Feedback ID. + * @param {any[]} items Item to fill the value. + * @param {boolean} offline True if it should return cached data. Has priority over ignoreCache. + * @param {boolean} ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} siteId Site ID. + * @return {Promise} Resolved with values when done. + */ + protected fillValues(feedbackId: number, items: any[], offline: boolean, ignoreCache: boolean, siteId: string): Promise { + return this.getCurrentValues(feedbackId, offline, ignoreCache, siteId).then((valuesArray) => { + if (valuesArray.length == 0) { + // Try sending empty values to get the last completed attempt values. + return this.processPageOnline(feedbackId, 0, {}, undefined, siteId).then(() => { + return this.getCurrentValues(feedbackId, offline, ignoreCache, siteId); + }).catch(() => { + // Ignore errors + }); + } + + return valuesArray; + + }).then((valuesArray) => { + const values = {}; + + valuesArray.forEach((value) => { + values[value.item] = value.value; + }); + + items.forEach((itemData) => { + if (itemData.hasvalue && typeof values[itemData.id] != 'undefined') { + itemData.rawValue = values[itemData.id]; + } + }); + }).catch(() => { + // Ignore errors. + }).then(() => { + // Merge with offline data. + return this.feedbackOffline.getFeedbackResponses(feedbackId, siteId).then((offlineValuesArray) => { + const offlineValues = {}; + + // Merge all values into one array. + offlineValuesArray = offlineValuesArray.reduce((a, b) => { + const responses = this.utils.objectToArrayOfObjects(b.responses, 'id', 'value'); + + return a.concat(responses); + }, []).map((a) => { + const parts = a.id.split('_'); + a.typ = parts[0]; + a.item = parseInt(parts[1], 0); + + return a; + }); + + offlineValuesArray.forEach((value) => { + if (typeof offlineValues[value.item] == 'undefined') { + offlineValues[value.item] = []; + } + offlineValues[value.item].push(value.value); + }); + + items.forEach((itemData) => { + if (itemData.hasvalue && typeof offlineValues[itemData.id] != 'undefined') { + // Treat multichoice checkboxes. + if (itemData.typ == 'multichoice' && + itemData.presentation.split(AddonModFeedbackProvider.MULTICHOICE_TYPE_SEP)[0] == 'c') { + + offlineValues[itemData.id] = offlineValues[itemData.id].filter((value) => { + return value > 0; + }); + itemData.rawValue = offlineValues[itemData.id].join(AddonModFeedbackProvider.LINE_SEP); + } else { + itemData.rawValue = offlineValues[itemData.id][0]; + } + } + }); + + return items; + }); + }).catch(() => { + // Ignore errors. + return items; + }); + } + + /** + * Returns all the feedback non respondents users. + * + * @param {number} feedbackId Feedback ID. + * @param {number} groupId Group id, 0 means that the function will determine the user group. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {any} [previous] Only for recurrent use. Object with the previous fetched info. + * @return {Promise} Promise resolved when the info is retrieved. + */ + getAllNonRespondents(feedbackId: number, groupId: number, siteId?: string, previous?: any): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + if (typeof previous == 'undefined') { + previous = { + page: 0, + users: [] + }; + } + + return this.getNonRespondents(feedbackId, groupId, previous.page, siteId).then((response) => { + if (previous.users.length < response.total) { + previous.users = previous.users.concat(response.users); + } + + if (previous.users.length < response.total) { + // Can load more. + previous.page++; + + return this.getAllNonRespondents(feedbackId, groupId, siteId, previous); + } + previous.total = response.total; + + return previous; + }); + } + + /** + * Returns all the feedback user responses. + * + * @param {number} feedbackId Feedback ID. + * @param {number} groupId Group id, 0 means that the function will determine the user group. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {any} [previous] Only for recurrent use. Object with the previous fetched info. + * @return {Promise} Promise resolved when the info is retrieved. + */ + getAllResponsesAnalysis(feedbackId: number, groupId: number, siteId?: string, previous?: any): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + if (typeof previous == 'undefined') { + previous = { + page: 0, + attempts: [], + anonattempts: [] + }; + } + + return this.getResponsesAnalysis(feedbackId, groupId, previous.page, siteId).then((responses) => { + if (previous.anonattempts.length < responses.totalanonattempts) { + previous.anonattempts = previous.anonattempts.concat(responses.anonattempts); + } + + if (previous.attempts.length < responses.totalattempts) { + previous.attempts = previous.attempts.concat(responses.attempts); + } + + if (previous.anonattempts.length < responses.totalanonattempts || previous.attempts.length < responses.totalattempts) { + // Can load more. + previous.page++; + + return this.getAllResponsesAnalysis(feedbackId, groupId, siteId, previous); + } + + previous.totalattempts = responses.totalattempts; + previous.totalanonattempts = responses.totalanonattempts; + + return previous; + }); + } + /** * Get analysis information for a given feedback. * @@ -436,6 +683,118 @@ export class AddonModFeedbackProvider { return this.getFeedbackDataPrefixCacheKey(feedbackId) + ':nonrespondents:'; } + /** + * Get a single feedback page items. This function is not cached, use AddonModFeedbackHelperProvider#getPageItems instead. + * + * @param {number} feedbackId Feedback ID. + * @param {number} page The page to get. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the info is retrieved. + */ + getPageItems(feedbackId: number, page: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + feedbackid: feedbackId, + page: page + }; + + return site.write('mod_feedback_get_page_items', params); + }); + } + + /** + * Get a single feedback page items. If offline or server down it will use getItems to calculate dependencies. + * + * @param {number} feedbackId Feedback ID. + * @param {number} page The page to get. + * @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 will 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. + */ + getPageItemsWithValues(feedbackId: number, page: number, offline: boolean = false, ignoreCache: boolean = false, + siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.getPageItems(feedbackId, page, siteId).then((response) => { + return this.fillValues(feedbackId, response.items, offline, ignoreCache, siteId).then((items) => { + response.items = items; + + return response; + }); + }).catch(() => { + // If getPageItems fail we should calculate it using getItems. + return this.getItems(feedbackId, siteId).then((response) => { + return this.fillValues(feedbackId, response.items, offline, ignoreCache, siteId).then((items) => { + // Separate items by pages. + let currentPage = 0; + const previousPageItems = []; + + const pageItems = items.filter((item) => { + // Greater page, discard all entries. + if (currentPage > page) { + return false; + } + + if (item.typ == 'pagebreak') { + currentPage++; + + return false; + } + + // Save items on previous page to check dependencies and discard entry. + if (currentPage < page) { + previousPageItems.push(item); + + return false; + } + + // Filter depending items. + if (item && item.dependitem > 0 && previousPageItems.length > 0) { + return this.checkDependencyItem(previousPageItems, item); + } + + // Filter items with errors. + return item; + }); + + // Check if there are more pages. + response.hasprevpage = page > 0; + response.hasnextpage = currentPage > page; + response.items = pageItems; + + return response; + }); + }); + }); + } + + /** + * Convenience function to get the page we can jump. + * + * @param {number} feedbackId [description] + * @param {number} page [description] + * @param {number} changePage [description] + * @param {string} siteId [description] + * @return {Promise} [description] + */ + protected getPageJumpTo(feedbackId: number, page: number, changePage: number, siteId: string): Promise { + return this.getPageItemsWithValues(feedbackId, page, true, false, siteId).then((resp) => { + // The page we are going has items. + if (resp.items.length > 0) { + return page; + } + + // Check we can jump futher. + if ((changePage == 1 && resp.hasnextpage) || (changePage == -1 && resp.hasprevpage)) { + return this.getPageJumpTo(feedbackId, page + changePage, changePage, siteId); + } + + // Completed or first page. + return false; + }); + } + /** * Returns the feedback user responses. * @@ -723,6 +1082,93 @@ export class AddonModFeedbackProvider { 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 {boolean} formHasErrors Whether the form we sent has required but empty fields (only used in offline). + * @param {number} courseId Course ID the feedback belongs to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the info is retrieved. + */ + processPage(feedbackId: number, page: number, responses: any, goPrevious: boolean, formHasErrors: boolean, courseId: number, + siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = (): Promise => { + return this.feedbackOffline.saveResponses(feedbackId, page, responses, courseId, siteId).then(() => { + // Simulate process_page response. + const response = { + jumpto: page, + completed: false, + offline: true + }; + let changePage = 0; + + if (goPrevious) { + if (page > 0) { + changePage = -1; + } + } else if (!formHasErrors) { + // We can only go next if it has no errors. + changePage = 1; + } + + if (changePage === 0) { + return response; + } + + return this.getPageItemsWithValues(feedbackId, page, true, false, siteId).then((resp) => { + // Check completion. + if (changePage == 1 && !resp.hasnextpage) { + response.completed = true; + + return response; + } + + return this.getPageJumpTo(feedbackId, page + changePage, changePage, siteId).then((loadPage) => { + if (loadPage === false) { + // Completed or first page. + if (changePage == -1) { + // First page. + response.jumpto = 0; + } else { + // Completed. + response.completed = true; + } + } else { + response.jumpto = loadPage; + } + + return response; + }); + }); + }); + }; + + if (!this.appProvider.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + // If there's already a response to be sent to the server, discard it first. + return this.feedbackOffline.deleteFeedbackPageResponses(feedbackId, page, siteId).then(() => { + return this.processPageOnline(feedbackId, page, responses, goPrevious, siteId).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + return Promise.reject(error); + } + + // Couldn't connect to server, store in offline. + return storeOffline(); + }); + }); + } + /** * Process a jump between pages. * diff --git a/src/addon/mod/feedback/providers/helper.ts b/src/addon/mod/feedback/providers/helper.ts index a6c9b48c3..4453ae5c1 100644 --- a/src/addon/mod/feedback/providers/helper.ts +++ b/src/addon/mod/feedback/providers/helper.ts @@ -94,6 +94,88 @@ export class AddonModFeedbackHelperProvider { }); } + /** + * Get page items responses to be sent. + * + * @param {any[]} items Items where the values are. + * @return {any} Responses object to be sent. + */ + getPageItemsResponses(items: any[]): any { + const responses = {}; + + items.forEach((itemData) => { + let answered = false; + + itemData.hasError = false; + + if (itemData.typ == 'captcha') { + const value = itemData.value || '', + name = itemData.typ + '_' + itemData.id; + + answered = !!value; + responses[name] = 1; + responses['g-recaptcha-response'] = value; + responses['recaptcha_element'] = 'dummyvalue'; + + if (itemData.required && !answered) { + // Check if it has any value. + itemData.isEmpty = true; + } else { + itemData.isEmpty = false; + } + } else if (itemData.hasvalue) { + let name, value; + const nameTemp = itemData.typ + '_' + itemData.id; + + if (itemData.typ == 'multichoice' && itemData.subtype == 'c') { + name = nameTemp + '[0]'; + responses[name] = 0; + itemData.choices.forEach((choice, index) => { + name = nameTemp + '[' + (index + 1) + ']'; + value = choice.checked ? choice.value : 0; + if (!answered && value) { + answered = true; + } + responses[name] = value; + }); + } else { + if (itemData.typ == 'multichoice') { + name = nameTemp + '[0]'; + } else { + name = nameTemp; + } + + if (itemData.typ == 'multichoice' || itemData.typ == 'multichoicerated') { + value = itemData.value || 0; + } else if (itemData.typ == 'numeric') { + value = itemData.value || itemData.value == 0 ? itemData.value : ''; + + if (value != '') { + if ((itemData.rangefrom != '' && value < itemData.rangefrom) || + (itemData.rangeto != '' && value > itemData.rangeto)) { + itemData.hasError = true; + } + } + } else { + value = itemData.value || itemData.value == 0 ? itemData.value : ''; + } + + answered = !!value; + responses[name] = value; + } + + if (itemData.required && !answered) { + // Check if it has any value. + itemData.isEmpty = true; + } else { + itemData.isEmpty = false; + } + } + }); + + return responses; + } + /** * Returns the feedback user responses with extra info. * @@ -269,8 +351,6 @@ export class AddonModFeedbackHelperProvider { parts = item.presentation.split(AddonModFeedbackProvider.MULTICHOICE_ADJUST_SEP) || []; item.presentation = parts.length > 0 ? parts[0] : ''; // Horizontal are not supported right now. item.horizontal = parts.length > 1 && !!parts[1]; - } else { - item.class = 'item-select'; } item.choices = item.presentation.split(AddonModFeedbackProvider.LINE_SEP) || []; @@ -320,9 +400,6 @@ export class AddonModFeedbackHelperProvider { const data = this.textUtils.parseJSON(item.otherdata); if (data && data.length > 3) { item.captcha = { - challengehash: data[0], - imageurl: data[1], - jsurl: data[2], recaptchapublickey: data[3] }; } diff --git a/src/addon/mod/feedback/providers/offline.ts b/src/addon/mod/feedback/providers/offline.ts index e905a6d22..a1fe0c39a 100644 --- a/src/addon/mod/feedback/providers/offline.ts +++ b/src/addon/mod/feedback/providers/offline.ts @@ -157,7 +157,7 @@ export class AddonModFeedbackOfflineProvider { timemodified: this.timeUtils.timestamp() }; - return site.getDb().insertOrUpdateRecord(this.FEEDBACK_TABLE, entry, {feedbackid: feedbackId, page: page}); + return site.getDb().insertRecord(this.FEEDBACK_TABLE, entry); }); } } diff --git a/src/addon/mod/feedback/providers/prefetch-handler.ts b/src/addon/mod/feedback/providers/prefetch-handler.ts index e2526b837..b5802f326 100644 --- a/src/addon/mod/feedback/providers/prefetch-handler.ts +++ b/src/addon/mod/feedback/providers/prefetch-handler.ts @@ -47,7 +47,7 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseModulePrefetchHan * 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 { + downloadOrPrefetch(module: any, courseId: number, prefetch?: boolean, dirPath?: string): Promise { const promises = [], siteId = this.sitesProvider.getCurrentSiteId(); @@ -119,7 +119,7 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseModulePrefetchHan })); return Promise.all(promises); - }*/ + } /** * Get the list of downloadable files. @@ -129,7 +129,7 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseModulePrefetchHan * @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 { + getFiles(module: any, courseId: number, single?: boolean): Promise { let files = []; return this.feedbackProvider.getFeedback(courseId, module.id).then((feedback) => { @@ -149,7 +149,7 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseModulePrefetchHan // Any error, return the list we have. return files; }); - }*/ + } /** * Returns feedback intro files. diff --git a/src/app/app.scss b/src/app/app.scss index 0e73a60bb..fc70cf588 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -567,8 +567,17 @@ textarea { height: auto; } -// Message cards +canvas[core-chart] { + max-width: 500px; + margin: 0 auto; +} + +.core-circle:before { + content: ' \25CF'; +} + @each $color-name, $color-base, $color-contrast in get-colors($colors) { + // Message cards. .core-#{$color-name}-card { @extend ion-card; border-bottom: 3px solid $color-base; @@ -589,21 +598,18 @@ textarea { } } } -} -canvas[core-chart] { - max-width: 500px; - margin: 0 auto; -} + .core-#{$color-name}-item { + border-bottom: 3px solid $color-base !important; + ion-icon { + color: $color-base; + } + } -.core-circle:before { - content: ' \25CF'; -} - -@each $color-name, $color-base, $color-contrast in get-colors($colors) { .core-#{$color-name}-circle { margin: 0 4px; } + .core-#{$color-name}-circle:before { @extend .core-circle:before; color: $color-base; diff --git a/src/core/course/classes/main-activity-component.ts b/src/core/course/classes/main-activity-component.ts index 523e86f50..3db66af55 100644 --- a/src/core/course/classes/main-activity-component.ts +++ b/src/core/course/classes/main-activity-component.ts @@ -59,7 +59,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR // Refresh online status when changes. this.onlineObserver = network.onchange().subscribe((online) => { - this.isOnline = this.appProvider.isOnline(); + this.isOnline = online; }); } diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index 2c485ed85..85ca2166b 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -170,9 +170,10 @@ export class CoreUtilsProvider { /** * Blocks leaving a view. + * @deprecated, use ionViewCanLeave instead. */ blockLeaveView(): void { - // @todo + return; } /** From 701d6f7774f16bf6ef08243e9bd7e389a043017f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 6 Apr 2018 17:04:39 +0200 Subject: [PATCH 8/9] MOBILE-2339 feedback: Add form link handlers --- .../mod/feedback/components/index/index.html | 4 +- src/addon/mod/feedback/feedback.module.ts | 10 ++- .../mod/feedback/pages/attempt/attempt.html | 2 +- src/addon/mod/feedback/pages/form/form.html | 9 +- .../providers/analysis-link-handler.ts | 2 +- .../providers/complete-link-handler.ts | 89 +++++++++++++++++++ .../mod/feedback/providers/module-handler.ts | 3 +- .../feedback/providers/print-link-handler.ts | 87 ++++++++++++++++++ .../providers/show-entries-link-handler.ts | 2 +- .../show-non-respondents-link-handler.ts | 2 +- 10 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 src/addon/mod/feedback/providers/complete-link-handler.ts create mode 100644 src/addon/mod/feedback/providers/print-link-handler.ts diff --git a/src/addon/mod/feedback/components/index/index.html b/src/addon/mod/feedback/components/index/index.html index 12040b63d..ccefec125 100644 --- a/src/addon/mod/feedback/components/index/index.html +++ b/src/addon/mod/feedback/components/index/index.html @@ -144,8 +144,8 @@ -

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

-

{{ item.label }}

+

{{item.number}}.

+

    diff --git a/src/addon/mod/feedback/feedback.module.ts b/src/addon/mod/feedback/feedback.module.ts index 3a1f67923..494e1769d 100644 --- a/src/addon/mod/feedback/feedback.module.ts +++ b/src/addon/mod/feedback/feedback.module.ts @@ -24,6 +24,8 @@ import { AddonModFeedbackLinkHandler } from './providers/link-handler'; import { AddonModFeedbackAnalysisLinkHandler } from './providers/analysis-link-handler'; import { AddonModFeedbackShowEntriesLinkHandler } from './providers/show-entries-link-handler'; import { AddonModFeedbackShowNonRespondentsLinkHandler } from './providers/show-non-respondents-link-handler'; +import { AddonModFeedbackCompleteLinkHandler } from './providers/complete-link-handler'; +import { AddonModFeedbackPrintLinkHandler } from './providers/print-link-handler'; import { AddonModFeedbackHelperProvider } from './providers/helper'; import { AddonModFeedbackPrefetchHandler } from './providers/prefetch-handler'; import { AddonModFeedbackSyncProvider } from './providers/sync'; @@ -45,6 +47,8 @@ import { AddonModFeedbackOfflineProvider } from './providers/offline'; AddonModFeedbackAnalysisLinkHandler, AddonModFeedbackShowEntriesLinkHandler, AddonModFeedbackShowNonRespondentsLinkHandler, + AddonModFeedbackCompleteLinkHandler, + AddonModFeedbackPrintLinkHandler, AddonModFeedbackSyncCronHandler, AddonModFeedbackSyncProvider, AddonModFeedbackOfflineProvider @@ -57,13 +61,17 @@ export class AddonModFeedbackModule { cronDelegate: CoreCronDelegate, syncHandler: AddonModFeedbackSyncCronHandler, analysisLinkHandler: AddonModFeedbackAnalysisLinkHandler, showEntriesLinkHandler: AddonModFeedbackShowEntriesLinkHandler, - showNonRespondentsLinkHandler: AddonModFeedbackShowNonRespondentsLinkHandler) { + showNonRespondentsLinkHandler: AddonModFeedbackShowNonRespondentsLinkHandler, + completeLinkHandler: AddonModFeedbackCompleteLinkHandler, + printLinkHandler: AddonModFeedbackPrintLinkHandler) { moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); contentLinksDelegate.registerHandler(linkHandler); contentLinksDelegate.registerHandler(analysisLinkHandler); contentLinksDelegate.registerHandler(showEntriesLinkHandler); contentLinksDelegate.registerHandler(showNonRespondentsLinkHandler); + contentLinksDelegate.registerHandler(completeLinkHandler); + contentLinksDelegate.registerHandler(printLinkHandler); cronDelegate.register(syncHandler); } } diff --git a/src/addon/mod/feedback/pages/attempt/attempt.html b/src/addon/mod/feedback/pages/attempt/attempt.html index f3e10d3ff..40df1356b 100644 --- a/src/addon/mod/feedback/pages/attempt/attempt.html +++ b/src/addon/mod/feedback/pages/attempt/attempt.html @@ -23,7 +23,7 @@

    - {{item.itemnumber}}. {{ item.name }} + {{item.itemnumber}}.

    diff --git a/src/addon/mod/feedback/pages/form/form.html b/src/addon/mod/feedback/pages/form/form.html index 22a9b8689..591b17a1d 100644 --- a/src/addon/mod/feedback/pages/form/form.html +++ b/src/addon/mod/feedback/pages/form/form.html @@ -16,7 +16,8 @@ - {{item.itemnumber}}. {{ item.name }} + {{item.itemnumber}}. +
    @@ -36,20 +37,20 @@ - {{option.label}} + - {{option.label}} + - {{option.label}} + diff --git a/src/addon/mod/feedback/providers/analysis-link-handler.ts b/src/addon/mod/feedback/providers/analysis-link-handler.ts index 12f03a387..8f8fa3c39 100644 --- a/src/addon/mod/feedback/providers/analysis-link-handler.ts +++ b/src/addon/mod/feedback/providers/analysis-link-handler.ts @@ -27,7 +27,7 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom'; @Injectable() export class AddonModFeedbackAnalysisLinkHandler extends CoreContentLinksHandlerBase { name = 'AddonModFeedbackAnalysisLinkHandler'; - featureName = '$mmCourseDelegate_mmaModFeedback'; + featureName = 'CoreCourseModuleDelegate_AddonModFeedback'; pattern = /\/mod\/feedback\/analysis\.php.*([\&\?]id=\d+)/; constructor(private linkHelper: CoreContentLinksHelperProvider, private feedbackProvider: AddonModFeedbackProvider, diff --git a/src/addon/mod/feedback/providers/complete-link-handler.ts b/src/addon/mod/feedback/providers/complete-link-handler.ts new file mode 100644 index 000000000..af4b1153a --- /dev/null +++ b/src/addon/mod/feedback/providers/complete-link-handler.ts @@ -0,0 +1,89 @@ +// (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 { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { AddonModFeedbackProvider } from './feedback'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; + +/** + * Content links handler for feedback complete questions. + * Match mod/feedback/complete.php with a valid feedback id. + */ +@Injectable() +export class AddonModFeedbackCompleteLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonModFeedbackCompleteLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModFeedback'; + pattern = /\/mod\/feedback\/complete\.php.*([\?\&](id|gopage)=\d+)/; + + constructor(private linkHelper: CoreContentLinksHelperProvider, private feedbackProvider: AddonModFeedbackProvider, + private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + return [{ + action: (siteId, navCtrl?): void => { + const modal = this.domUtils.showModalLoading(), + moduleId = params.id; + + this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((module) => { + const stateParams = { + module: module, + moduleId: module.id, + courseId: module.course + }; + if (typeof params.gopage == 'undefined') { + stateParams['page'] = parseInt(params.gopage, 10); + } + + return this.linkHelper.goInSite(navCtrl, 'AddonModFeedbackFormPage', stateParams, siteId); + }).finally(() => { + modal.dismiss(); + }); + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + if (typeof params.id == 'undefined') { + return false; + } + + return this.feedbackProvider.isPluginEnabled(siteId); + } +} diff --git a/src/addon/mod/feedback/providers/module-handler.ts b/src/addon/mod/feedback/providers/module-handler.ts index 725565779..59dbbdd67 100644 --- a/src/addon/mod/feedback/providers/module-handler.ts +++ b/src/addon/mod/feedback/providers/module-handler.ts @@ -24,7 +24,8 @@ import { AddonModFeedbackProvider } from './feedback'; */ @Injectable() export class AddonModFeedbackModuleHandler implements CoreCourseModuleHandler { - name = 'feedback'; + name = 'AddonModFeedback'; + modName = 'feedback'; constructor(private courseProvider: CoreCourseProvider, private feedbackProvider: AddonModFeedbackProvider) { } diff --git a/src/addon/mod/feedback/providers/print-link-handler.ts b/src/addon/mod/feedback/providers/print-link-handler.ts new file mode 100644 index 000000000..3be523950 --- /dev/null +++ b/src/addon/mod/feedback/providers/print-link-handler.ts @@ -0,0 +1,87 @@ +// (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 { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { AddonModFeedbackProvider } from './feedback'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; + +/** + * Content links handler for feedback print questions. + * Match mod/feedback/print.php with a valid feedback id. + */ +@Injectable() +export class AddonModFeedbackPrintLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonModFeedbackPrintLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModFeedback'; + pattern = /\/mod\/feedback\/print\.php.*([\?\&](id)=\d+)/; + + constructor(private linkHelper: CoreContentLinksHelperProvider, private feedbackProvider: AddonModFeedbackProvider, + private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + return [{ + action: (siteId, navCtrl?): void => { + const modal = this.domUtils.showModalLoading(), + moduleId = params.id; + + this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((module) => { + const stateParams = { + module: module, + moduleId: module.id, + courseId: module.course, + preview: true + }; + + return this.linkHelper.goInSite(navCtrl, 'AddonModFeedbackFormPage', stateParams, siteId); + }).finally(() => { + modal.dismiss(); + }); + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + if (typeof params.id == 'undefined') { + return false; + } + + return this.feedbackProvider.isPluginEnabled(siteId); + } +} diff --git a/src/addon/mod/feedback/providers/show-entries-link-handler.ts b/src/addon/mod/feedback/providers/show-entries-link-handler.ts index 74e61ff5e..f640c9163 100644 --- a/src/addon/mod/feedback/providers/show-entries-link-handler.ts +++ b/src/addon/mod/feedback/providers/show-entries-link-handler.ts @@ -27,7 +27,7 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom'; @Injectable() export class AddonModFeedbackShowEntriesLinkHandler extends CoreContentLinksHandlerBase { name = 'AddonModFeedbackShowEntriesLinkHandler'; - featureName = '$mmCourseDelegate_mmaModFeedback'; + featureName = 'CoreCourseModuleDelegate_AddonModFeedback'; pattern = /\/mod\/feedback\/show_entries\.php.*([\?\&](id|showcompleted)=\d+)/; constructor(private linkHelper: CoreContentLinksHelperProvider, private feedbackProvider: AddonModFeedbackProvider, diff --git a/src/addon/mod/feedback/providers/show-non-respondents-link-handler.ts b/src/addon/mod/feedback/providers/show-non-respondents-link-handler.ts index 5d1ab4cb3..cb88ecaa3 100644 --- a/src/addon/mod/feedback/providers/show-non-respondents-link-handler.ts +++ b/src/addon/mod/feedback/providers/show-non-respondents-link-handler.ts @@ -27,7 +27,7 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom'; @Injectable() export class AddonModFeedbackShowNonRespondentsLinkHandler extends CoreContentLinksHandlerBase { name = 'AddonModFeedbackShowNonRespondentsLinkHandler'; - featureName = '$mmCourseDelegate_mmaModFeedback'; + featureName = 'CoreCourseModuleDelegate_AddonModFeedback'; pattern = /\/mod\/feedback\/show_nonrespondents\.php.*([\?\&](id)=\d+)/; constructor(private linkHelper: CoreContentLinksHelperProvider, private feedbackProvider: AddonModFeedbackProvider, From 12adb9ef19cf2efb89fc03db119611f6d3c667c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 12 Apr 2018 15:15:16 +0200 Subject: [PATCH 9/9] MOBILE-2339 feedback: Peer review fixes --- src/addon/mod/feedback/pages/form/form.html | 4 +--- .../mod/feedback/pages/form/form.module.ts | 2 -- src/addon/mod/feedback/providers/feedback.ts | 18 +++++++++--------- src/addon/mod/feedback/providers/helper.ts | 9 +++++---- src/addon/mod/feedback/providers/offline.ts | 8 ++++++-- src/addon/mod/feedback/providers/sync.ts | 17 +++++++++++++---- src/addon/mod/survey/providers/sync.ts | 6 +++--- src/components/recaptcha/recaptcha.ts | 2 +- src/core/user/providers/user.ts | 2 +- src/directives/chart.ts | 2 +- 10 files changed, 40 insertions(+), 30 deletions(-) diff --git a/src/addon/mod/feedback/pages/form/form.html b/src/addon/mod/feedback/pages/form/form.html index 591b17a1d..722813335 100644 --- a/src/addon/mod/feedback/pages/form/form.html +++ b/src/addon/mod/feedback/pages/form/form.html @@ -29,7 +29,7 @@ -

    {{ 'addon.mod_feedback.numberoutofrange' |translate }} [{{item.rangefrom}}, {{item.rangeto}}]

    +

    {{ 'addon.mod_feedback.numberoutofrange' | translate }} [{{item.rangefrom}}, {{item.rangeto}}]

    @@ -111,7 +111,5 @@ - - diff --git a/src/addon/mod/feedback/pages/form/form.module.ts b/src/addon/mod/feedback/pages/form/form.module.ts index 854f15034..c71c52917 100644 --- a/src/addon/mod/feedback/pages/form/form.module.ts +++ b/src/addon/mod/feedback/pages/form/form.module.ts @@ -17,7 +17,6 @@ import { IonicPageModule } from 'ionic-angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CoreComponentsModule } from '@components/components.module'; -import { CorePipesModule } from '@pipes/pipes.module'; import { AddonModFeedbackComponentsModule } from '../../components/components.module'; import { AddonModFeedbackFormPage } from './form'; @@ -28,7 +27,6 @@ import { AddonModFeedbackFormPage } from './form'; imports: [ CoreDirectivesModule, CoreComponentsModule, - CorePipesModule, AddonModFeedbackComponentsModule, IonicPageModule.forChild(AddonModFeedbackFormPage), TranslateModule.forChild() diff --git a/src/addon/mod/feedback/providers/feedback.ts b/src/addon/mod/feedback/providers/feedback.ts index 3b5fa4291..c28d69193 100644 --- a/src/addon/mod/feedback/providers/feedback.ts +++ b/src/addon/mod/feedback/providers/feedback.ts @@ -77,7 +77,7 @@ export class AddonModFeedbackProvider { * * @param {any} item Item to check. * @param {string} dependValue Value to compare. - * @return {boolean} eturn true if dependency is acomplished and it can be shown. False, otherwise. + * @return {boolean} Return true if dependency is acomplished and it can be shown. False, otherwise. */ protected compareDependItemMultichoice(item: any, dependValue: string): boolean { let values, choices; @@ -772,11 +772,11 @@ export class AddonModFeedbackProvider { /** * Convenience function to get the page we can jump. * - * @param {number} feedbackId [description] - * @param {number} page [description] - * @param {number} changePage [description] - * @param {string} siteId [description] - * @return {Promise} [description] + * @param {number} feedbackId Feedback ID. + * @param {number} page Page where we want to jump. + * @param {number} changePage If page change is forward (1) or backward (-1). + * @param {string} siteId Site ID. + * @return {Promise} Page number where to jump. Or false if completed or first page. */ protected getPageJumpTo(feedbackId: number, page: number, changePage: number, siteId: string): Promise { return this.getPageItemsWithValues(feedbackId, page, true, false, siteId).then((resp) => { @@ -1037,9 +1037,9 @@ export class AddonModFeedbackProvider { * * @param {number} feedbackId Feedback ID. * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved when the info is retrieved. + * @return {Promise} Promise resolved when the info is retrieved. */ - isCompleted(feedbackId: number, siteId?: string): Promise { + isCompleted(feedbackId: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const params = { feedbackid: feedbackId @@ -1092,7 +1092,7 @@ export class AddonModFeedbackProvider { * @param {boolean} formHasErrors Whether the form we sent has required but empty fields (only used in offline). * @param {number} courseId Course ID the feedback belongs to. * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved when the info is retrieved. + * @return {Promise} Promise resolved when the info is retrieved. */ processPage(feedbackId: number, page: number, responses: any, goPrevious: boolean, formHasErrors: boolean, courseId: number, siteId?: string): Promise { diff --git a/src/addon/mod/feedback/providers/helper.ts b/src/addon/mod/feedback/providers/helper.ts index 4453ae5c1..7f5527623 100644 --- a/src/addon/mod/feedback/providers/helper.ts +++ b/src/addon/mod/feedback/providers/helper.ts @@ -37,10 +37,11 @@ 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. + * @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. + * @param {NavController} navCtrl Nav Controller of the view. * @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, diff --git a/src/addon/mod/feedback/providers/offline.ts b/src/addon/mod/feedback/providers/offline.ts index a1fe0c39a..ffa69a4fd 100644 --- a/src/addon/mod/feedback/providers/offline.ts +++ b/src/addon/mod/feedback/providers/offline.ts @@ -27,7 +27,7 @@ export class AddonModFeedbackOfflineProvider { protected logger; // Variables for database. - protected FEEDBACK_TABLE = 'mma_mod_feedback_answers'; + protected FEEDBACK_TABLE = 'addon_mod_feedback_answers'; protected tablesSchema = [ { name: this.FEEDBACK_TABLE, @@ -102,7 +102,11 @@ export class AddonModFeedbackOfflineProvider { */ getFeedbackResponses(feedbackId: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { - return site.getDb().getRecords(this.FEEDBACK_TABLE, {feedbackid: feedbackId}); + return site.getDb().getRecords(this.FEEDBACK_TABLE, {feedbackid: feedbackId}).then((entries) => { + return entries.map((entry) => { + entry.responses = this.textUtils.parseJSON(entry.responses); + }); + }); }); } diff --git a/src/addon/mod/feedback/providers/sync.ts b/src/addon/mod/feedback/providers/sync.ts index 143ed10a5..7653014b8 100644 --- a/src/addon/mod/feedback/providers/sync.ts +++ b/src/addon/mod/feedback/providers/sync.ts @@ -38,9 +38,9 @@ export class AddonModFeedbackSyncProvider extends CoreSyncBaseProvider { 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, + protected translate: TranslateService, private utils: CoreUtilsProvider, protected textUtils: CoreTextUtilsProvider, courseProvider: CoreCourseProvider, syncProvider: CoreSyncProvider) { - super('AddonModFeedbackSyncProvider', sitesProvider, loggerProvider, appProvider, syncProvider, textUtils); + super('AddonModFeedbackSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); this.componentTranslate = courseProvider.translateModuleName('feedback'); } @@ -108,7 +108,7 @@ export class AddonModFeedbackSyncProvider extends CoreSyncBaseProvider { } /** - * ynchronize all offline responses of a feedback. + * Synchronize all offline responses of a feedback. * * @param {number} feedbackId Feedback ID to be synced. * @param {string} [siteId] Site ID. If not defined, current site. @@ -221,7 +221,16 @@ export class AddonModFeedbackSyncProvider extends CoreSyncBaseProvider { return this.addOngoingSync(syncId, syncPromise, siteId); } - // Convenience function to sync process page calls. + /** + * Convenience function to sync process page calls. + * + * @param {any} feedback Feedback object. + * @param {any} data Response data. + * @param {string} siteId Site Id. + * @param {number} timemodified Current completed modification time. + * @param {any} result Result object to be modified. + * @return {Promise} Resolve when done or rejected with error. + */ 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) { diff --git a/src/addon/mod/survey/providers/sync.ts b/src/addon/mod/survey/providers/sync.ts index 9e7ae338a..d6fc54f02 100644 --- a/src/addon/mod/survey/providers/sync.ts +++ b/src/addon/mod/survey/providers/sync.ts @@ -97,9 +97,9 @@ export class AddonModSurveySyncProvider extends CoreSyncBaseProvider { /** * Sync a survey only if a certain time has passed since the last time. * - * @param {Number} surveyId Survey ID. - * @param {Number} userId User the answers belong to. - * @param {String} [siteId] Site ID. If not defined, current site. + * @param {number} surveyId Survey ID. + * @param {number} userId User the answers belong to. + * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the survey is synced or if it doesn't need to be synced. */ syncSurveyIfNeeded(surveyId: number, userId: number, siteId?: string): Promise { diff --git a/src/components/recaptcha/recaptcha.ts b/src/components/recaptcha/recaptcha.ts index d8f2f76af..d9ccfa674 100644 --- a/src/components/recaptcha/recaptcha.ts +++ b/src/components/recaptcha/recaptcha.ts @@ -77,7 +77,7 @@ export class CoreRecaptchaComponent { } @Component({ - selector: 'core-recaptcha', + selector: 'core-recaptcha-modal', templateUrl: 'recaptchamodal.html' }) export class CoreRecaptchaModalComponent { diff --git a/src/core/user/providers/user.ts b/src/core/user/providers/user.ts index 5aeae8ec9..3464a8d0f 100644 --- a/src/core/user/providers/user.ts +++ b/src/core/user/providers/user.ts @@ -376,7 +376,7 @@ export class CoreUserProvider { * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when prefetched. */ - prefetchProfiles(userIds: number[], courseId: number, siteId?: string): Promise { + prefetchProfiles(userIds: number[], courseId?: number, siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); const treated = {}, diff --git a/src/directives/chart.ts b/src/directives/chart.ts index c036bef72..3da2dd1ad 100644 --- a/src/directives/chart.ts +++ b/src/directives/chart.ts @@ -18,7 +18,7 @@ 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. + * It only supports changes on these properties: data and labels. * * Example usage: *