589 lines
21 KiB
TypeScript

// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { CoreCourse } from '@features/course/services/course';
import { CoreUser } from '@features/user/services/user';
import { CoreNavigator } from '@services/navigator';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton, Translate } from '@singletons';
import {
AddonModFeedback,
AddonModFeedbackGetNonRespondentsWSResponse,
AddonModFeedbackGetResponsesAnalysisWSResponse,
AddonModFeedbackGroupPaginatedOptions,
AddonModFeedbackItem,
AddonModFeedbackProvider,
AddonModFeedbackResponseValue,
AddonModFeedbackWSAttempt,
AddonModFeedbackWSNonRespondent,
} from './feedback';
import { AddonModFeedbackModuleHandlerService } from './handlers/module';
const MODE_RESPONSETIME = 1;
const MODE_COURSE = 2;
const MODE_CATEGORY = 3;
/**
* Service that provides helper functions for feedbacks.
*/
@Injectable({ providedIn: 'root' })
export class AddonModFeedbackHelperProvider {
/**
* Retrieves a list of students who didn't submit the feedback with extra info.
*
* @param feedbackId Feedback ID.
* @param options Other options.
* @returns Promise resolved when the info is retrieved.
*/
async getNonRespondents(
feedbackId: number,
options: AddonModFeedbackGroupPaginatedOptions = {},
): Promise<AddonModFeedbackGetNonRespondents> {
const responses: AddonModFeedbackGetNonRespondents = await AddonModFeedback.getNonRespondents(feedbackId, options);
responses.users = await this.addImageProfile(responses.users);
return responses;
}
/**
* Get page items responses to be sent.
*
* @param items Items where the values are.
* @returns Responses object to be sent.
*/
getPageItemsResponses(items: AddonModFeedbackFormItem[]): Record<string, AddonModFeedbackResponseValue> {
const responses: Record<string, AddonModFeedbackResponseValue> = {};
items.forEach((itemData) => {
let answered = false;
itemData.hasError = false;
if (itemData.typ == 'captcha') {
const value = itemData.value || '';
const 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: string;
let value: AddonModFeedbackResponseValue;
const nameTemp = itemData.typ + '_' + itemData.id;
if (this.isMultiChoiceItem(itemData) && 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 (this.isMultiChoiceItem(itemData) && itemData.subtype != 'r') {
name = nameTemp + '[0]';
} else {
name = nameTemp;
}
if (itemData.typ == 'multichoice' || itemData.typ == 'multichoicerated') {
value = itemData.value || 0;
} else if (this.isNumericItem(itemData)) {
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.
*
* @param feedbackId Feedback ID.
* @param options Other options.
* @returns Promise resolved when the info is retrieved.
*/
async getResponsesAnalysis(
feedbackId: number,
options: AddonModFeedbackGroupPaginatedOptions = {},
): Promise<AddonModFeedbackResponsesAnalysis> {
const responses: AddonModFeedbackResponsesAnalysis = await AddonModFeedback.getResponsesAnalysis(feedbackId, options);
responses.attempts = await this.addImageProfile(responses.attempts);
return responses;
}
/**
* Handle a show entries link.
*
* @param params URL params.
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when done.
*/
async handleShowEntriesLink(params: Record<string, string>, siteId?: string): Promise<void> {
siteId = siteId || CoreSites.getCurrentSiteId();
const modal = await CoreDomUtils.showModalLoading();
try {
const module = await CoreCourse.getModuleBasicInfo(
Number(params.id),
{ siteId, readingStrategy: CoreSitesReadingStrategy.PREFER_CACHE },
);
if (params.showcompleted === undefined) {
// Param showcompleted not defined. Show entry list.
await CoreNavigator.navigateToSitePath(
AddonModFeedbackModuleHandlerService.PAGE_NAME + `/${module.course}/${module.id}/attempts`,
{ siteId },
);
return;
}
const attempt = await AddonModFeedback.getAttempt(module.instance, Number(params.showcompleted), {
cmId: module.id,
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
siteId,
});
await CoreNavigator.navigateToSitePath(
AddonModFeedbackModuleHandlerService.PAGE_NAME + `/${module.course}/${module.id}/attempts/${attempt.id}`,
{
params: {
feedbackId: module.instance,
attempt: attempt,
},
siteId,
},
);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error opening link.');
} finally {
modal.dismiss();
}
}
/**
* Add Image profile url field on some entries.
*
* @param entries Entries array to get profile from.
* @returns Returns the same array with the profileimageurl added if found.
*/
async addImageProfile(entries: AddonModFeedbackWSAttempt[]): Promise<AddonModFeedbackAttempt[]>;
async addImageProfile(entries: AddonModFeedbackWSNonRespondent[]): Promise<AddonModFeedbackNonRespondent[]>;
async addImageProfile(
entries: (AddonModFeedbackWSAttempt | AddonModFeedbackWSNonRespondent)[],
): Promise<(AddonModFeedbackAttempt | AddonModFeedbackNonRespondent)[]> {
return Promise.all(entries.map(async (entry: AddonModFeedbackAttempt | AddonModFeedbackNonRespondent) => {
try {
const user = await CoreUser.getProfile(entry.userid, entry.courseid, true);
entry.profileimageurl = user.profileimageurl;
} catch {
// Error getting profile, resolve promise without adding any extra data.
}
return entry;
}));
}
/**
* Helper funtion for item type Label.
*
* @param item Item to process.
* @returns Item processed to show form.
*/
protected getItemFormLabel(item: AddonModFeedbackItem): AddonModFeedbackFormBasicItem {
item.name = '';
item.presentation = CoreTextUtils.replacePluginfileUrls(item.presentation, item.itemfiles);
return Object.assign(item, {
templateName: 'label',
value: '',
slottedLabel: false,
});
}
/**
* Helper funtion for item type Info.
*
* @param item Item to process.
* @returns Item processed to show form.
*/
protected getItemFormInfo(item: AddonModFeedbackItem): AddonModFeedbackFormBasicItem | undefined {
const formItem: AddonModFeedbackFormBasicItem = Object.assign(item, {
templateName: 'label',
value: '',
slottedLabel: false,
});
const type = parseInt(formItem.presentation, 10);
if (type == MODE_COURSE || type == MODE_CATEGORY) {
formItem.presentation = formItem.otherdata;
formItem.value = formItem.rawValue !== undefined ? formItem.rawValue : formItem.otherdata;
} else if (type == MODE_RESPONSETIME) {
formItem.value = '__CURRENT__TIMESTAMP__';
const rawValue = Number(formItem.rawValue);
const tempValue = isNaN(rawValue) ? Date.now() : rawValue * 1000;
formItem.presentation = CoreTimeUtils.userDate(tempValue);
} else {
// Errors on item, return false.
return undefined;
}
return formItem;
}
/**
* Helper funtion for item type Numeric.
*
* @param item Item to process.
* @returns Item processed to show form.
*/
protected getItemFormNumeric(item: AddonModFeedbackItem): AddonModFeedbackNumericItem {
const range = item.presentation.split(AddonModFeedbackProvider.LINE_SEP) || [];
const rangeFrom = range.length > 0 ? parseInt(range[0], 10) : undefined;
const rangeTo = range.length > 1 ? parseInt(range[1], 10) : undefined;
const formItem: AddonModFeedbackNumericItem = Object.assign(item, {
templateName: 'numeric',
value: item.rawValue !== undefined ? Number(item.rawValue) : '',
rangefrom: typeof rangeFrom == 'number' && !isNaN(rangeFrom) ? range[0] : '',
rangeto: typeof rangeTo == 'number' && !isNaN(rangeTo) ? rangeTo : '',
slottedLabel: true,
});
formItem.postfix = this.getNumericBoundariesForDisplay(formItem.rangefrom, formItem.rangeto);
return formItem;
}
/**
* Helper funtion for item type Text field.
*
* @param item Item to process.
* @returns Item processed to show form.
*/
protected getItemFormTextfield(item: AddonModFeedbackItem): AddonModFeedbackTextItem {
return Object.assign(item, {
templateName: 'textfield',
length: Number(item.presentation.split(AddonModFeedbackProvider.LINE_SEP)[1]) || 255,
value: item.rawValue !== undefined ? item.rawValue : '',
slottedLabel: true,
});
}
/**
* Helper funtion for item type Textarea.
*
* @param item Item to process.
* @returns Item processed to show form.
*/
protected getItemFormTextarea(item: AddonModFeedbackItem): AddonModFeedbackFormBasicItem {
return Object.assign(item, {
templateName: 'textarea',
value: item.rawValue !== undefined ? item.rawValue : '',
slottedLabel: true,
});
}
/**
* Helper funtion for item type Multichoice.
*
* @param item Item to process.
* @returns Item processed to show form.
*/
protected getItemFormMultichoice(item: AddonModFeedbackItem): AddonModFeedbackMultichoiceItem {
let parts = item.presentation.split(AddonModFeedbackProvider.MULTICHOICE_TYPE_SEP) || [];
const subType = parts.length > 0 && parts[0] ? parts[0] : 'r';
const formItem: AddonModFeedbackMultichoiceItem = Object.assign(item, {
templateName: 'multichoice-' + subType,
subtype: subType,
value: '',
choices: [],
slottedLabel: subType === 'd',
});
formItem.presentation = parts.length > 1 ? parts[1] : '';
if (formItem.subtype != 'd') {
parts = formItem.presentation.split(AddonModFeedbackProvider.MULTICHOICE_ADJUST_SEP) || [];
formItem.presentation = parts.length > 0 ? parts[0] : '';
// Horizontal are not supported right now. item.horizontal = parts.length > 1 && !!parts[1];
}
const choices = formItem.presentation.split(AddonModFeedbackProvider.LINE_SEP) || [];
formItem.choices = 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 (formItem.subtype === 'r' && formItem.options.search(AddonModFeedbackProvider.MULTICHOICE_HIDENOSELECT) == -1) {
formItem.choices.unshift({ value: 0, label: Translate.instant('addon.mod_feedback.not_selected') });
formItem.value = formItem.rawValue !== undefined ? Number(formItem.rawValue) : 0;
} else if (formItem.subtype === 'd') {
formItem.choices.unshift({ value: 0, label: '' });
formItem.value = formItem.rawValue !== undefined ? Number(formItem.rawValue) : 0;
} else if (formItem.subtype === 'c') {
if (formItem.rawValue !== undefined) {
formItem.rawValue = String(formItem.rawValue);
const values = formItem.rawValue.split(AddonModFeedbackProvider.LINE_SEP);
formItem.choices.forEach((choice) => {
for (const x in values) {
if (choice.value == Number(values[x])) {
choice.checked = true;
return;
}
}
});
}
} else {
formItem.value = formItem.rawValue !== undefined ? Number(formItem.rawValue) : '';
}
return formItem;
}
/**
* Helper funtion for item type Captcha.
*
* @param item Item to process.
* @returns Item processed to show form.
*/
protected getItemFormCaptcha(item: AddonModFeedbackItem): AddonModFeedbackCaptchaItem {
const formItem: AddonModFeedbackCaptchaItem = Object.assign(item, {
templateName: 'captcha',
value: '',
slottedLabel: false,
});
const data = <string[]> CoreTextUtils.parseJSON(item.otherdata);
if (data && data.length > 3) {
formItem.captcha = {
recaptchapublickey: data[3],
};
}
return formItem;
}
/**
* Process and returns item to print form.
*
* @param item Item to process.
* @param preview Previewing options.
* @returns Item processed to show form.
*/
getItemForm(item: AddonModFeedbackItem, preview: boolean): AddonModFeedbackFormItem | undefined {
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 undefined;
}
break;
case 'captcha':
// Captcha is not supported right now. However label will be shown.
return this.getItemFormCaptcha(item);
default:
return undefined;
}
}
/**
* Returns human-readable boundaries (min - max).
* Based on Moodle's get_boundaries_for_display.
*
* @param rangeFrom Range from.
* @param rangeTo Range to.
* @returns Human-readable boundaries.
*/
protected getNumericBoundariesForDisplay(rangeFrom: number | string, rangeTo: number | string): string {
const rangeFromSet = typeof rangeFrom == 'number';
const rangeToSet = typeof rangeTo == 'number';
if (!rangeFromSet && rangeToSet) {
return ' (' + Translate.instant('addon.mod_feedback.maximal') + ': ' + CoreUtils.formatFloat(rangeTo) + ')';
} else if (rangeFromSet && !rangeToSet) {
return ' (' + Translate.instant('addon.mod_feedback.minimal') + ': ' + CoreUtils.formatFloat(rangeFrom) + ')';
} else if (!rangeFromSet && !rangeToSet) {
return '';
}
return ' (' + CoreUtils.formatFloat(rangeFrom) + ' - ' + CoreUtils.formatFloat(rangeTo) + ')';
}
/**
* Check if a form item is multichoice.
*
* @param item Item.
* @returns Whether item is multichoice.
*/
protected isMultiChoiceItem(item: AddonModFeedbackFormItem): item is AddonModFeedbackMultichoiceItem {
return item.typ == 'multichoice';
}
/**
* Check if a form item is numeric.
*
* @param item Item.
* @returns Whether item is numeric.
*/
protected isNumericItem(item: AddonModFeedbackFormItem): item is AddonModFeedbackNumericItem {
return item.typ == 'numeric';
}
}
export const AddonModFeedbackHelper = makeSingleton(AddonModFeedbackHelperProvider);
/**
* Attempt with some calculated data.
*/
export type AddonModFeedbackAttempt = AddonModFeedbackWSAttempt & {
profileimageurl?: string;
};
/**
* Non respondent with some calculated data.
*/
export type AddonModFeedbackNonRespondent = AddonModFeedbackWSNonRespondent & {
profileimageurl?: string;
};
/**
* Non respondents with some calculated data.
*/
export type AddonModFeedbackResponsesAnalysis = Omit<AddonModFeedbackGetResponsesAnalysisWSResponse, 'attempts'> & {
attempts: AddonModFeedbackAttempt[];
};
/**
* Non respondents with some calculated data.
*/
export type AddonModFeedbackGetNonRespondents = Omit<AddonModFeedbackGetNonRespondentsWSResponse, 'users'> & {
users: AddonModFeedbackNonRespondent[];
};
/**
* Item with form data.
*/
export type AddonModFeedbackFormItem =
AddonModFeedbackFormBasicItem | AddonModFeedbackNumericItem | AddonModFeedbackTextItem | AddonModFeedbackMultichoiceItem |
AddonModFeedbackCaptchaItem;
/**
* Common calculated data for all form items.
*/
export type AddonModFeedbackFormBasicItem = AddonModFeedbackItem & {
templateName: string;
value: AddonModFeedbackResponseValue;
slottedLabel: boolean;
isEmpty?: boolean;
hasError?: boolean;
};
/**
* Numeric item.
*/
export type AddonModFeedbackNumericItem = AddonModFeedbackFormBasicItem & {
rangefrom: number | string;
rangeto: number | string;
postfix?: string;
};
/**
* Text item.
*/
export type AddonModFeedbackTextItem = AddonModFeedbackFormBasicItem & {
length: number;
};
/**
* Multichoice item.
*/
export type AddonModFeedbackMultichoiceItem = AddonModFeedbackFormBasicItem & {
subtype: string;
choices: { value: number; label: string; checked?: boolean }[];
};
/**
* Captcha item.
*/
export type AddonModFeedbackCaptchaItem = AddonModFeedbackFormBasicItem & {
captcha?: {
recaptchapublickey: string;
};
};