MOBILE-3639 choice: Implement index page
parent
18757924bb
commit
53d808ad76
|
@ -0,0 +1,38 @@
|
|||
// (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 { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
import { AddonModChoiceComponentsModule } from './components/components.module';
|
||||
import { AddonModChoiceIndexPage } from './pages/index/index';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: ':courseId/:cmId',
|
||||
component: AddonModChoiceIndexPage,
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CoreSharedModule,
|
||||
AddonModChoiceComponentsModule,
|
||||
],
|
||||
declarations: [
|
||||
AddonModChoiceIndexPage,
|
||||
],
|
||||
})
|
||||
export class AddonModChoiceLazyModule {}
|
|
@ -13,19 +13,22 @@
|
|||
// limitations under the License.
|
||||
|
||||
import { APP_INITIALIZER, NgModule, Type } from '@angular/core';
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate';
|
||||
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
|
||||
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
|
||||
import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
|
||||
import { CoreCronDelegate } from '@services/cron';
|
||||
import { CORE_SITE_SCHEMAS } from '@services/sites';
|
||||
import { AddonModChoiceComponentsModule } from './components/components.module';
|
||||
import { AddonModChoiceProvider } from './services/choice';
|
||||
import { AddonModChoiceOfflineProvider } from './services/choice-offline';
|
||||
import { AddonModChoiceSyncProvider } from './services/choice-sync';
|
||||
import { OFFLINE_SITE_SCHEMA } from './services/database/choice';
|
||||
import { AddonModChoiceIndexLinkHandler } from './services/handlers/index-link';
|
||||
import { AddonModChoiceListLinkHandler } from './services/handlers/list-link';
|
||||
import { AddonModChoiceModuleHandler } from './services/handlers/module';
|
||||
import { AddonModChoiceModuleHandler, AddonModChoiceModuleHandlerService } from './services/handlers/module';
|
||||
import { AddonModChoicePrefetchHandler } from './services/handlers/prefetch';
|
||||
import { AddonModChoiceSyncCronHandler } from './services/handlers/sync-cron';
|
||||
|
||||
|
@ -35,8 +38,17 @@ export const ADDON_MOD_CHOICE_SERVICES: Type<unknown>[] = [
|
|||
AddonModChoiceSyncProvider,
|
||||
];
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: AddonModChoiceModuleHandlerService.PAGE_NAME,
|
||||
loadChildren: () => import('./choice-lazy.module').then(m => m.AddonModChoiceLazyModule),
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CoreMainMenuTabRoutingModule.forChild(routes),
|
||||
AddonModChoiceComponentsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
// (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 { CoreSharedModule } from '@/core/shared.module';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
|
||||
import { AddonModChoiceIndexComponent } from './index/index';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AddonModChoiceIndexComponent,
|
||||
],
|
||||
imports: [
|
||||
CoreSharedModule,
|
||||
CoreCourseComponentsModule,
|
||||
],
|
||||
providers: [
|
||||
],
|
||||
exports: [
|
||||
AddonModChoiceIndexComponent,
|
||||
],
|
||||
})
|
||||
export class AddonModChoiceComponentsModule {}
|
|
@ -0,0 +1,175 @@
|
|||
<!-- Buttons to add to the header. -->
|
||||
<core-navbar-buttons slot="end">
|
||||
<core-context-menu>
|
||||
<core-context-menu-item *ngIf="externalUrl" [priority]="900" [content]="'core.openinbrowser' | translate"
|
||||
[href]="externalUrl" iconAction="fas-external-link-alt">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item *ngIf="description" [priority]="800" [content]="'core.moduleintro' | translate"
|
||||
(action)="expandDescription()" iconAction="fas-arrow-right">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}"
|
||||
iconAction="far-newspaper" (action)="gotoBlog()">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'core.refresh' | translate"
|
||||
(action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item *ngIf="loaded && hasOffline && isOnline" [priority]="600" (action)="doRefresh(null, $event, true)"
|
||||
[content]="'core.settings.synchronizenow' | translate" [iconAction]="syncIcon" [closeOnClick]="false">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch($event)"
|
||||
[iconAction]="prefetchStatusIcon" [closeOnClick]="false">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.clearstoreddata' | translate:{$a: size}"
|
||||
iconDescription="fas-archive" (action)="removeFiles($event)" iconAction="fas-trash" [closeOnClick]="false">
|
||||
</core-context-menu-item>
|
||||
</core-context-menu>
|
||||
</core-navbar-buttons>
|
||||
|
||||
<!-- Content. -->
|
||||
<core-loading [hideUntil]="loaded" class="core-loading-center">
|
||||
|
||||
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
|
||||
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
|
||||
</core-course-module-description>
|
||||
|
||||
<!-- Activity availability messages -->
|
||||
<ion-card class="core-info-card" *ngIf="choiceNotOpenYet">
|
||||
<ion-item>
|
||||
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
|
||||
<ion-label>
|
||||
<p *ngIf="options.length">{{ 'addon.mod_choice.previewonly' | translate:{$a: openTimeReadable} }}</p>
|
||||
<p *ngIf="!options.length">{{ 'addon.mod_choice.notopenyet' | translate:{$a: openTimeReadable} }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<ion-card class="core-info-card" *ngIf="choiceClosed">
|
||||
<ion-item>
|
||||
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
|
||||
<ion-label>
|
||||
<p *ngIf="options.length">
|
||||
{{ 'addon.mod_choice.yourselection' | translate }}
|
||||
<core-format-text [text]="options[0].text" contextLevel="module" [contextInstanceId]="module.id"
|
||||
[courseId]="courseId">
|
||||
</core-format-text>
|
||||
</p>
|
||||
<p>{{ 'addon.mod_choice.expired' | translate:{$a: closeTimeReadable} }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<!-- Choice done in offline but not synchronized -->
|
||||
<ion-card class="core-warning-card" *ngIf="hasOffline">
|
||||
<ion-item>
|
||||
<ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
|
||||
<ion-label>{{ 'core.hasdatatosync' | translate:{$a: moduleName} }}</ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<!-- Inform what will happen with the choices. -->
|
||||
<ion-card class="core-info-card" *ngIf="canEdit && publishInfo && options.length">
|
||||
<ion-item>
|
||||
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
|
||||
<ion-label>{{ publishInfo | translate }}</ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<!-- Choice options -->
|
||||
<ion-card *ngIf="options.length && choice">
|
||||
<ng-container *ngIf="choice.allowmultiple">
|
||||
<ion-item class="ion-text-wrap" *ngFor="let option of options">
|
||||
<ion-label>
|
||||
<ng-container *ngTemplateOutlet="optionLabelTemplate; context: {option: option}"></ng-container>
|
||||
</ion-label>
|
||||
<ion-checkbox slot="end" [(ngModel)]="option.checked" [disabled]="option.disabled || !canEdit"></ion-checkbox>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
<ion-radio-group *ngIf="!choice.allowmultiple" [(ngModel)]="selectedOption.id">
|
||||
<ion-item class="ion-text-wrap" *ngFor="let option of options">
|
||||
<ion-label>
|
||||
<ng-container *ngTemplateOutlet="optionLabelTemplate; context: {option: option}"></ng-container>
|
||||
</ion-label>
|
||||
<ion-radio slot="end" [value]="option.id" [disabled]="option.disabled || !canEdit"></ion-radio>
|
||||
</ion-item>
|
||||
</ion-radio-group>
|
||||
<ion-button *ngIf="canEdit" expand="block" (click)="save()" [disabled]="!canSave()" class="ion-margin">
|
||||
{{ 'addon.mod_choice.savemychoice' | translate }}
|
||||
</ion-button>
|
||||
<ion-button *ngIf="canDelete" expand="block" color="light" (click)="delete()" class="ion-margin">
|
||||
{{ 'addon.mod_choice.removemychoice' | translate }}
|
||||
</ion-button>
|
||||
</ion-card>
|
||||
|
||||
<!-- Choice results -->
|
||||
<div *ngIf="canSeeResults && choice">
|
||||
<ion-item-divider>
|
||||
<ion-label>
|
||||
<h2>{{ 'addon.mod_choice.responses' | translate }}</h2>
|
||||
</ion-label>
|
||||
</ion-item-divider>
|
||||
<ion-grid class="ion-no-padding">
|
||||
<ion-row>
|
||||
<ion-col size="12" size-lg="5">
|
||||
<ion-item class="ion-text-wrap core-warning-item" *ngIf="hasOffline">
|
||||
<ion-icon slot="start" name="fas-exclamation-triangle" color="warning"></ion-icon>
|
||||
<ion-label>{{ 'addon.mod_choice.resultsnotsynced' | translate }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<core-chart type="pie" [data]="data" [labels]="labels" height="300" contextLevel="module"
|
||||
[contextInstanceId]="module.id" [courseId]="courseId">
|
||||
</core-chart>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-col>
|
||||
<ion-col *ngIf="choice.publish && results" size="12" size-lg="7">
|
||||
<ion-item-group *ngFor="let result of results">
|
||||
<ion-item-divider class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<h2>
|
||||
<core-format-text [text]="result.text" contextLevel="module" [contextInstanceId]="module.id"
|
||||
[courseId]="courseId">
|
||||
</core-format-text>
|
||||
</h2>
|
||||
<p>
|
||||
{{ 'addon.mod_choice.numberofuser' | translate }}: {{ result.numberofuser }}
|
||||
({{ 'core.percentagenumber' | translate: {$a: result.percentageamountfixed} }})
|
||||
</p>
|
||||
<p *ngIf="choice.limitanswers && choice.showavailable">
|
||||
{{ 'addon.mod_choice.limita' | translate:{$a: result.maxanswer} }}
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item-divider>
|
||||
<ion-item *ngFor="let user of result.userresponses" core-user-link [courseId]="courseId"
|
||||
[userId]="user.userid" [title]="user.fullname" class="ion-text-wrap">
|
||||
<core-user-avatar [user]="user" slot="start" [courseId]="courseId"></core-user-avatar>
|
||||
<ion-label><p>{{user.fullname}}</p></ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</div>
|
||||
|
||||
<ion-card class="core-info-card" *ngIf="!canSeeResults && !choiceNotOpenYet">
|
||||
<ion-item>
|
||||
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
|
||||
<ion-label><p>{{ 'addon.mod_choice.noresultsviewable' | translate }}</p></ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
</core-loading>
|
||||
|
||||
<!-- Template to render a choice option label. -->
|
||||
<ng-template #optionLabelTemplate let-option="option">
|
||||
<p>
|
||||
<core-format-text [text]="option.text" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
<span *ngIf="choice!.limitanswers && option.countanswers >= option.maxanswers">
|
||||
{{ 'addon.mod_choice.full' | translate }}
|
||||
</span>
|
||||
</p>
|
||||
<ng-container *ngIf="choice!.limitanswers && choice!.showavailable">
|
||||
<p>{{ 'addon.mod_choice.responsesa' | translate:{$a: option.countanswers} }}</p>
|
||||
<p>{{ 'addon.mod_choice.limita' | translate:{$a: option.maxanswers} }}</p>
|
||||
</ng-container>
|
||||
</ng-template>
|
|
@ -0,0 +1,479 @@
|
|||
// (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 { Component, Optional, OnInit } from '@angular/core';
|
||||
import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component';
|
||||
import { CoreCourseContentsPage } from '@features/course/pages/contents/contents';
|
||||
import { CoreCourse } from '@features/course/services/course';
|
||||
import { IonContent } from '@ionic/angular';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreTimeUtils } from '@services/utils/time';
|
||||
import { Translate } from '@singletons';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
import {
|
||||
AddonModChoice,
|
||||
AddonModChoiceChoice,
|
||||
AddonModChoiceOption,
|
||||
AddonModChoiceProvider,
|
||||
AddonModChoiceResult,
|
||||
} from '../../services/choice';
|
||||
import { AddonModChoiceOffline } from '../../services/choice-offline';
|
||||
import {
|
||||
AddonModChoiceAutoSyncData,
|
||||
AddonModChoiceSync,
|
||||
AddonModChoiceSyncProvider,
|
||||
AddonModChoiceSyncResult,
|
||||
} from '../../services/choice-sync';
|
||||
import { AddonModChoicePrefetchHandler } from '../../services/handlers/prefetch';
|
||||
|
||||
/**
|
||||
* Component that displays a choice.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'addon-mod-choice-index',
|
||||
templateUrl: 'addon-mod-choice-index.html',
|
||||
})
|
||||
export class AddonModChoiceIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit {
|
||||
|
||||
component = AddonModChoiceProvider.COMPONENT;
|
||||
moduleName = 'choice';
|
||||
|
||||
choice?: AddonModChoiceChoice;
|
||||
options: AddonModChoiceOption[] = [];
|
||||
selectedOption: {id: number} = { id: -1 };
|
||||
choiceNotOpenYet = false;
|
||||
choiceClosed = false;
|
||||
canEdit = false;
|
||||
canDelete = false;
|
||||
canSeeResults = false;
|
||||
data: number[] = [];
|
||||
labels: string[] = [];
|
||||
results: AddonModChoiceResultFormatted[] = [];
|
||||
publishInfo?: string; // Message explaining the user what will happen with his choices.
|
||||
openTimeReadable?: string;
|
||||
closeTimeReadable?: string;
|
||||
|
||||
protected userId?: number;
|
||||
protected syncEventName = AddonModChoiceSyncProvider.AUTO_SYNCED;
|
||||
protected hasAnsweredOnline = false;
|
||||
protected now = Date.now();
|
||||
|
||||
constructor(
|
||||
protected content?: IonContent,
|
||||
@Optional() courseContentsPage?: CoreCourseContentsPage,
|
||||
) {
|
||||
super('AddonModChoiceIndexComponent', content, courseContentsPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async ngOnInit(): Promise<void> {
|
||||
super.ngOnInit();
|
||||
|
||||
this.userId = CoreSites.getCurrentSiteUserId();
|
||||
|
||||
await this.loadContent(false, true);
|
||||
|
||||
if (!this.choice) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await AddonModChoice.logView(this.choice.id, this.choice.name);
|
||||
|
||||
await CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata);
|
||||
} catch {
|
||||
// Ignore errors.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected async invalidateContent(): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
promises.push(AddonModChoice.invalidateChoiceData(this.courseId));
|
||||
|
||||
if (this.choice) {
|
||||
promises.push(AddonModChoice.invalidateOptions(this.choice.id));
|
||||
promises.push(AddonModChoice.invalidateResults(this.choice.id));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected isRefreshSyncNeeded(syncEventData: AddonModChoiceAutoSyncData): boolean {
|
||||
if (this.choice && syncEventData.choiceId == this.choice.id && syncEventData.userId == this.userId) {
|
||||
this.content?.scrollToTop();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> {
|
||||
this.now = Date.now();
|
||||
|
||||
try {
|
||||
this.choice = await AddonModChoice.getChoice(this.courseId, this.module.id);
|
||||
|
||||
if (sync) {
|
||||
// Try to synchronize the choice.
|
||||
const updated = await this.syncActivity(showErrors);
|
||||
|
||||
if (updated) {
|
||||
// Responses were sent, update the choice.
|
||||
this.choice = await AddonModChoice.getChoice(this.courseId, this.module.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.choice.timeopen = (this.choice.timeopen || 0) * 1000;
|
||||
this.choice.timeclose = (this.choice.timeclose || 0) * 1000;
|
||||
this.openTimeReadable = CoreTimeUtils.userDate(this.choice.timeopen);
|
||||
this.closeTimeReadable = CoreTimeUtils.userDate(this.choice.timeclose);
|
||||
|
||||
this.description = this.choice.intro;
|
||||
this.choiceNotOpenYet = !!this.choice.timeopen && this.choice.timeopen > this.now;
|
||||
this.choiceClosed = !!this.choice.timeclose && this.choice.timeclose <= this.now;
|
||||
|
||||
this.dataRetrieved.emit(this.choice);
|
||||
|
||||
// Check if there are responses stored in offline.
|
||||
this.hasOffline = await AddonModChoiceOffline.hasResponse(this.choice.id);
|
||||
|
||||
// We need fetchOptions to finish before calling fetchResults because it needs hasAnsweredOnline variable.
|
||||
await this.fetchOptions(this.choice);
|
||||
|
||||
await this.fetchResults(this.choice);
|
||||
} finally {
|
||||
this.fillContextMenu(refresh);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get choice options.
|
||||
*
|
||||
* @param choice Choice data.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchOptions(choice: AddonModChoiceChoice): Promise<void> {
|
||||
let options = await AddonModChoice.getOptions(choice.id, { cmId: this.module.id });
|
||||
|
||||
// Check if the user has answered (synced) to allow show results.
|
||||
this.hasAnsweredOnline = options.some((option) => option.checked);
|
||||
|
||||
if (this.hasOffline) {
|
||||
options = await this.getOfflineResponses(choice, options);
|
||||
}
|
||||
|
||||
const isOpen = this.isChoiceOpen(choice);
|
||||
|
||||
this.selectedOption = { id: -1 }; // Single choice model.
|
||||
const hasAnswered = options.some((option) => {
|
||||
if (!option.checked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!choice.allowmultiple) {
|
||||
this.selectedOption.id = option.id;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
this.canEdit = isOpen && (choice.allowupdate! || !hasAnswered);
|
||||
this.canDelete = isOpen && choice.allowupdate! && hasAnswered;
|
||||
this.options = options;
|
||||
|
||||
if (!this.canEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the publish info message.
|
||||
switch (choice.showresults) {
|
||||
case AddonModChoiceProvider.RESULTS_NOT:
|
||||
this.publishInfo = 'addon.mod_choice.publishinfonever';
|
||||
break;
|
||||
|
||||
case AddonModChoiceProvider.RESULTS_AFTER_ANSWER:
|
||||
if (choice.publish == AddonModChoiceProvider.PUBLISH_ANONYMOUS) {
|
||||
this.publishInfo = 'addon.mod_choice.publishinfoanonafter';
|
||||
} else {
|
||||
this.publishInfo = 'addon.mod_choice.publishinfofullafter';
|
||||
}
|
||||
break;
|
||||
|
||||
case AddonModChoiceProvider.RESULTS_AFTER_CLOSE:
|
||||
if (choice.publish == AddonModChoiceProvider.PUBLISH_ANONYMOUS) {
|
||||
this.publishInfo = 'addon.mod_choice.publishinfoanonclose';
|
||||
} else {
|
||||
this.publishInfo = 'addon.mod_choice.publishinfofullclose';
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// No need to inform the user since it's obvious that the results are being published.
|
||||
this.publishInfo = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get offline responses.
|
||||
*
|
||||
* @param choice Choice.
|
||||
* @param options Online options.
|
||||
* @return Promise resolved with the options.
|
||||
*/
|
||||
protected async getOfflineResponses(
|
||||
choice: AddonModChoiceChoice,
|
||||
options: AddonModChoiceOption[],
|
||||
): Promise<AddonModChoiceOption[]> {
|
||||
const response = await AddonModChoiceOffline.getResponse(choice.id);
|
||||
|
||||
const optionsMap: {[id: number]: AddonModChoiceOption} = {};
|
||||
options.forEach((option) => {
|
||||
optionsMap[option.id] = option;
|
||||
});
|
||||
|
||||
// Update options with the offline data.
|
||||
if (response.deleting) {
|
||||
// Uncheck selected options.
|
||||
if (response.responses.length > 0) {
|
||||
// Uncheck all options selected in responses.
|
||||
response.responses.forEach((selected) => {
|
||||
if (optionsMap[selected] && optionsMap[selected].checked) {
|
||||
optionsMap[selected].checked = false;
|
||||
optionsMap[selected].countanswers--;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// On empty responses, uncheck all selected.
|
||||
Object.keys(optionsMap).forEach((key) => {
|
||||
if (optionsMap[key].checked) {
|
||||
optionsMap[key].checked = false;
|
||||
optionsMap[key].countanswers--;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Uncheck all options to check again the offlines'.
|
||||
Object.keys(optionsMap).forEach((key) => {
|
||||
if (optionsMap[key].checked) {
|
||||
optionsMap[key].checked = false;
|
||||
optionsMap[key].countanswers--;
|
||||
}
|
||||
});
|
||||
// Then check selected ones.
|
||||
response.responses.forEach((selected) => {
|
||||
if (optionsMap[selected]) {
|
||||
optionsMap[selected].checked = true;
|
||||
optionsMap[selected].countanswers++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Convert it again to array.
|
||||
return Object.keys(optionsMap).map((key) => optionsMap[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get choice results.
|
||||
*
|
||||
* @param choice Choice.
|
||||
* @return Resolved when done.
|
||||
*/
|
||||
protected async fetchResults(choice: AddonModChoiceChoice): Promise<void> {
|
||||
if (this.choiceNotOpenYet) {
|
||||
// Cannot see results yet.
|
||||
this.canSeeResults = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await AddonModChoice.getResults(choice.id, { cmId: this.module.id });
|
||||
|
||||
let hasVotes = false;
|
||||
this.data = [];
|
||||
this.labels = [];
|
||||
|
||||
this.results = results.map((result: AddonModChoiceResultFormatted) => {
|
||||
if (result.numberofuser > 0) {
|
||||
hasVotes = true;
|
||||
}
|
||||
this.data.push(result.numberofuser);
|
||||
this.labels.push(result.text);
|
||||
|
||||
return Object.assign(result, { percentageamountfixed: result.percentageamount.toFixed(1) });
|
||||
});
|
||||
this.canSeeResults = hasVotes || AddonModChoice.canStudentSeeResults(choice, this.hasAnsweredOnline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a choice is open.
|
||||
*
|
||||
* @param choice Choice data.
|
||||
* @return True if choice is open, false otherwise.
|
||||
*/
|
||||
protected isChoiceOpen(choice: AddonModChoiceChoice): boolean {
|
||||
return (!choice.timeopen || choice.timeopen <= this.now) && (!choice.timeclose || choice.timeclose > this.now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the user has selected at least one option.
|
||||
*
|
||||
* @return True if the user has responded.
|
||||
*/
|
||||
canSave(): boolean {
|
||||
if (!this.choice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.choice.allowmultiple) {
|
||||
return this.options.some((option) => option.checked);
|
||||
} else {
|
||||
return this.selectedOption.id !== -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save options selected.
|
||||
*/
|
||||
async save(): Promise<void> {
|
||||
const choice = this.choice!;
|
||||
|
||||
// Only show confirm if choice doesn't allow update.
|
||||
if (!choice.allowupdate) {
|
||||
await CoreDomUtils.showConfirm(Translate.instant('core.areyousure'));
|
||||
}
|
||||
|
||||
const responses: number[] = [];
|
||||
if (choice.allowmultiple) {
|
||||
this.options.forEach((option) => {
|
||||
if (option.checked) {
|
||||
responses.push(option.id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
responses.push(this.selectedOption.id);
|
||||
}
|
||||
|
||||
const modal = await CoreDomUtils.showModalLoading('core.sending', true);
|
||||
|
||||
try {
|
||||
const online = await AddonModChoice.submitResponse(choice.id, choice.name, this.courseId, responses);
|
||||
|
||||
this.content?.scrollToTop();
|
||||
|
||||
if (online) {
|
||||
CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: this.moduleName });
|
||||
// Check completion since it could be configured to complete once the user answers the choice.
|
||||
CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata);
|
||||
}
|
||||
|
||||
await this.dataUpdated(online);
|
||||
} catch (error) {
|
||||
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_choice.cannotsubmit', true);
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete options selected.
|
||||
*/
|
||||
async delete(): Promise<void> {
|
||||
try {
|
||||
await CoreDomUtils.showDeleteConfirm();
|
||||
} catch {
|
||||
// User cancelled.
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = await CoreDomUtils.showModalLoading('core.sending', true);
|
||||
|
||||
try {
|
||||
await AddonModChoice.deleteResponses(this.choice!.id, this.choice!.name, this.courseId);
|
||||
|
||||
this.content?.scrollToTop();
|
||||
|
||||
// Refresh the data. Don't call dataUpdated because deleting an answer doesn't mark the choice as outdated.
|
||||
await this.refreshContent(false);
|
||||
} catch (error) {
|
||||
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_choice.cannotsubmit', true);
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to call when some data has changed. It will refresh/prefetch data.
|
||||
*
|
||||
* @param online Whether the data was sent to server or stored in offline.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async dataUpdated(online: boolean): Promise<void> {
|
||||
if (!online || !this.isPrefetched) {
|
||||
// Not downloaded, just refresh the data.
|
||||
return this.refreshContent(false);
|
||||
}
|
||||
|
||||
try {
|
||||
// The choice is downloaded, update the data.
|
||||
await AddonModChoiceSync.prefetchAfterUpdate(AddonModChoicePrefetchHandler.instance, this.module, this.courseId);
|
||||
|
||||
// Update the view.
|
||||
this.showLoadingAndFetch(false, false);
|
||||
} catch {
|
||||
// Prefetch failed, refresh the data.
|
||||
return this.refreshContent(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the sync of the activity.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected sync(): Promise<AddonModChoiceSyncResult> {
|
||||
return AddonModChoiceSync.syncChoice(this.choice!.id, this.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if sync has succeed from result sync data.
|
||||
*
|
||||
* @param result Data returned on the sync function.
|
||||
* @return Whether it succeed or not.
|
||||
*/
|
||||
protected hasSyncSucceed(result: AddonModChoiceSyncResult): boolean {
|
||||
return result.updated;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Choice result with some calculated data.
|
||||
*/
|
||||
export type AddonModChoiceResultFormatted = AddonModChoiceResult & {
|
||||
percentageamountfixed: string; // Percentage of users answers with fixed decimals.
|
||||
};
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"cannotsubmit": "Sorry, there was a problem submitting your choice. Please try again.",
|
||||
"choiceoptions": "Choice options",
|
||||
"errorgetchoice": "Error getting choice data.",
|
||||
"expired": "This activity closed on {{$a}}.",
|
||||
"full": "(Full)",
|
||||
"limita": "Limit: {{$a}}",
|
||||
"modulenameplural": "Choices",
|
||||
"noresultsviewable": "The results are not currently viewable.",
|
||||
"notopenyet": "This activity is not available until {{$a}}.",
|
||||
"numberofuser": "Number of responses",
|
||||
"numberofuserinpercentage": "Percentage of responses",
|
||||
"previewonly": "This is just a preview of the available options for this activity. You will not be able to submit your choice until {{$a}}.",
|
||||
"publishinfoanonafter": "Anonymous results will be published after you answer.",
|
||||
"publishinfoanonclose": "Anonymous results will be published after the activity is closed.",
|
||||
"publishinfofullafter": "Full results, showing everyone's choices, will be published after you answer.",
|
||||
"publishinfofullclose": "Full results, showing everyone's choices, will be published after the activity is closed.",
|
||||
"publishinfonever": "The results of this activity will not be published after you answer.",
|
||||
"removemychoice": "Remove my choice",
|
||||
"responses": "Responses",
|
||||
"responsesa": "Responses: {{$a}}",
|
||||
"responsesresultgraphdescription": "{{number}}% of the users chose the option: {{text}}.",
|
||||
"responsesresultgraphheader": "Graph display",
|
||||
"resultsnotsynced": "Your last response must be synchronised before it is included in the results.",
|
||||
"savemychoice": "Save my choice",
|
||||
"userchoosethisoption": "Users who chose this option",
|
||||
"yourselection": "Your selection"
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>
|
||||
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="module?.id" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<!-- The buttons defined by the component will be added in here. -->
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<ion-refresher slot="fixed" [disabled]="!activityComponent?.loaded" (ionRefresh)="activityComponent?.doRefresh($event.target)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<addon-mod-choice-index [module]="module" [courseId]="courseId" (dataRetrieved)="updateData($event)"></addon-mod-choice-index>
|
||||
</ion-content>
|
|
@ -0,0 +1,30 @@
|
|||
// (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 { Component, ViewChild } from '@angular/core';
|
||||
import { CoreCourseModuleMainActivityPage } from '@features/course/classes/main-activity-page';
|
||||
import { AddonModChoiceIndexComponent } from '../../components/index/index';
|
||||
|
||||
/**
|
||||
* Page that displays a choice.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'page-addon-mod-choice-index',
|
||||
templateUrl: 'index.html',
|
||||
})
|
||||
export class AddonModChoiceIndexPage extends CoreCourseModuleMainActivityPage<AddonModChoiceIndexComponent> {
|
||||
|
||||
@ViewChild(AddonModChoiceIndexComponent) activityComponent?: AddonModChoiceIndexComponent;
|
||||
|
||||
}
|
Loading…
Reference in New Issue