MOBILE-2339 feedback: Implement form page

main
Pau Ferrer Ocaña 2018-03-23 08:38:00 +01:00
parent fca428843e
commit d8d34a786a
13 changed files with 1074 additions and 27 deletions

View File

@ -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);

View File

@ -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."

View File

@ -0,0 +1,116 @@
<ion-header>
<ion-navbar>
<ion-title><core-format-text [text]=" title "></core-format-text></ion-title>
</ion-navbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="feedbackLoaded">
<ng-container *ngIf="items && items.length">
<ion-list no-margin>
<ion-item text-wrap>
<h2>{{ 'addon.mod_feedback.mode' | translate }}</h2>
<p *ngIf="access.isanonymous">{{ 'addon.mod_feedback.anonymous' | translate }}</p>
<p *ngIf="!access.isanonymous">{{ 'addon.mod_feedback.non_anonymous' | translate }}</p>
</ion-item>
<ng-container *ngFor="let item of items">
<ion-item-divider *ngIf="item.typ == 'pagebreak'" color="light"></ion-item-divider>
<ion-item text-wrap *ngIf="item.typ != 'pagebreak'" [color]="item.dependitem > 0 ? 'light' : ''" [class.core-danger-item]="item.isEmpty || item.hasError">
<ion-label *ngIf="item.name" [core-mark-required]="item.required" stacked>
<span *ngIf="item.itemnumber">{{item.itemnumber}}. </span>{{ item.name }}
</ion-label>
<div item-content class="addon-mod_feedback-form-content" *ngIf="item.template">
<ng-container [ngSwitch]="item.template">
<ng-container *ngSwitchCase="'label'">
<p><core-format-text [component]="component" [componentId]="componentId" [text]="item.presentation"></core-format-text></p>
</ng-container>
<ng-container *ngSwitchCase="'textfield'">
<ion-input type="text" [(ngModel)]="item.value" autocorrect="off" name="{{item.typ}}_{{item.id}}" maxlength="{{item.maxlength}}" [required]="item.required"></ion-input>
</ng-container>
<ng-container *ngSwitchCase="'numeric'">
<ion-input [required]="item.required" name="{{item.typ}}_{{item.id}}" type="number" [(ngModel)]="item.value"></ion-input>
<p *ngIf="item.hasError" color="error">{{ 'addon.mod_feedback.numberoutofrange' |translate }} [{{item.rangefrom}}<span *ngIf="item.rangefrom && item.rangeto">, </span>{{item.rangeto}}]</p>
</ng-container>
<ng-container *ngSwitchCase="'textarea'">
<ion-textarea [required]="item.required" name="{{item.typ}}_{{item.id}}" [attr.aria-multiline]="true" [(ngModel)]="item.value"></ion-textarea>
</ng-container>
<ng-container *ngSwitchCase="'multichoice-r'">
<ion-list radio-group [(ngModel)]="item.value" [required]="item.required" name="{{item.typ}}_{{item.id}}">
<ion-item *ngFor="let option of item.choices">
<ion-label>{{option.label}}</ion-label>
<ion-radio [value]="option.value"></ion-radio>
</ion-item>
</ion-list>
</ng-container>
<ion-list *ngSwitchCase="'multichoice-c'">
<ion-item *ngFor="let option of item.choices">
<ion-label>{{option.label}}</ion-label>
<ion-checkbox [required]="item.required" name="{{item.typ}}_{{item.id}}" [(ngModel)]="option.checked" value="option.value"></ion-checkbox>
</ion-item>
</ion-list>
<ng-container *ngSwitchCase="'multichoice-d'">
<ion-select [required]="item.required" name="{{item.typ}}_{{item.id}}" [(ngModel)]="item.value">
<ion-option *ngFor="let option of item.choices" [value]="option.value">{{option.label}}</ion-option>
</ion-select>
</ng-container>
<ng-container *ngSwitchCase="'captcha'">
<core-recaptcha *ngIf="!preview && !offline" [publicKey]="item.captcha.recaptchapublickey" [model]="item" modelValueName="value"></core-recaptcha>
<div *ngIf="!preview && (!item.captcha || offline)" class="core-warning-card" icon-start>
<ion-icon name="warning"></ion-icon>
{{ 'addon.mod_feedback.captchaofflinewarning' | translate }}
</div>
</ng-container>
</ng-container>
</div>
</ion-item>
</ng-container>
<ion-grid>
<ion-row align-items-center>
<ion-col *ngIf="hasPrevPage">
<button ion-button block outline icon-start (click)="gotoPage(true)">
<ion-icon name="arrow-back"></ion-icon>
{{ 'addon.mod_feedback.previous_page' | translate }}
</button>
</ion-col>
<ion-col *ngIf="hasNextPage">
<button ion-button block icon-end (click)="gotoPage(false)">
{{ 'addon.mod_feedback.next_page' | translate }}
<ion-icon name="arrow-forward"></ion-icon>
</button>
</ion-col>
<ion-col *ngIf="!hasNextPage">
<button ion-button block (click)="gotoPage(false)">
{{ 'addon.mod_feedback.save_entries' | translate }}
</button>
</ion-col>
</ion-row>
</ion-grid>
</ion-list>
</ng-container>
<div class="core-success-card" icon-start *ngIf="completed">
<ion-icon name="checkmark"></ion-icon>
<p *ngIf="!completionPageContents && !completedOffline">{{ 'addon.mod_feedback.this_feedback_is_already_submitted' | translate }}</p>
<p *ngIf="!completionPageContents && completedOffline">{{ 'addon.mod_feedback.feedback_submitted_offline' | translate }}</p>
<p *ngIf="completionPageContents"><core-format-text [component]="component" componentId="componentId" [text]="completionPageContents"></core-format-text></p>
</div>
<ion-grid *ngIf="completed">
<ion-row align-items-center>
<ion-col *ngIf="access.canviewanalysis">
<button ion-button block outline icon-start (click)="showAnalysis()">
<ion-icon name="stats"></ion-icon>
{{ 'addon.mod_feedback.completed_feedbacks' | translate }}
</button>
</ion-col>
<ion-col *ngIf="hasNextPage">
<button ion-button block icon-end (click)="continue()">
{{ 'core.continue' | translate }}
<ion-icon name="arrow-forward"></ion-icon>
</button>
</ion-col>
</ion-row>
</ion-grid>
</core-loading>
</ion-content>

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -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<void>} Resolved if we can leave it, rejected if not.
*/
ionViewCanLeave(): boolean | Promise<void> {
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<any>} Promise resolved when done.
*/
protected fetchData(): Promise<any> {
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<any>} Promise resolved when done.
*/
protected fetchAccessData(): Promise<any> {
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<void> {
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<void>} Resolved when done.
*/
gotoPage(goPrevious: boolean): Promise<void> {
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();
}
}

View File

@ -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<any>} Resolved with values when done.
*/
protected fillValues(feedbackId: number, items: any[], offline: boolean, ignoreCache: boolean, siteId: string): Promise<any> {
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<any>} Promise resolved when the info is retrieved.
*/
getAllNonRespondents(feedbackId: number, groupId: number, siteId?: string, previous?: any): Promise<any> {
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<any>} Promise resolved when the info is retrieved.
*/
getAllResponsesAnalysis(feedbackId: number, groupId: number, siteId?: string, previous?: any): Promise<any> {
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<any>} Promise resolved when the info is retrieved.
*/
getPageItems(feedbackId: number, page: number, siteId?: string): Promise<any> {
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<any>} Promise resolved when the info is retrieved.
*/
getPageItemsWithValues(feedbackId: number, page: number, offline: boolean = false, ignoreCache: boolean = false,
siteId?: string): Promise<any> {
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<number | false>} [description]
*/
protected getPageJumpTo(feedbackId: number, page: number, changePage: number, siteId: string): Promise<number | false> {
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<any>} Promise resolved when the info is retrieved.
*/
processPage(feedbackId: number, page: number, responses: any, goPrevious: boolean, formHasErrors: boolean, courseId: number,
siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
// Convenience function to store a message to be synchronized later.
const storeOffline = (): Promise<any> => {
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.
*

View File

@ -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]
};
}

View File

@ -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);
});
}
}

View File

@ -47,7 +47,7 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseModulePrefetchHan
* in the filepool root feedback.
* @return {Promise<any>} Promise resolved when all content is downloaded. Data returned is not reliable.
*/
/*downloadOrPrefetch(module: any, courseId: number, prefetch?: boolean, dirPath?: string): Promise<any> {
downloadOrPrefetch(module: any, courseId: number, prefetch?: boolean, dirPath?: string): Promise<any> {
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<any>} Promise resolved with the list of files.
*/
/*getFiles(module: any, courseId: number, single?: boolean): Promise<any[]> {
getFiles(module: any, courseId: number, single?: boolean): Promise<any[]> {
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.

View File

@ -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;

View File

@ -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;
});
}

View File

@ -170,9 +170,10 @@ export class CoreUtilsProvider {
/**
* Blocks leaving a view.
* @deprecated, use ionViewCanLeave instead.
*/
blockLeaveView(): void {
// @todo
return;
}
/**