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; } /**