MOBILE-3939 feedback: Attempts swipe navigation
parent
00a12df79b
commit
12e30f1c86
|
@ -0,0 +1,163 @@
|
|||
// (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 { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source';
|
||||
import { CoreGroupInfo, CoreGroups } from '@services/groups';
|
||||
import {
|
||||
AddonModFeedback,
|
||||
AddonModFeedbackProvider,
|
||||
AddonModFeedbackWSAnonAttempt,
|
||||
AddonModFeedbackWSAttempt,
|
||||
AddonModFeedbackWSFeedback,
|
||||
} from '../services/feedback';
|
||||
import { AddonModFeedbackHelper } from '../services/feedback-helper';
|
||||
|
||||
/**
|
||||
* Feedback attempts.
|
||||
*/
|
||||
export class AddonModFeedbackAttemptsSource extends CoreRoutedItemsManagerSource<AddonModFeedbackAttemptItem> {
|
||||
|
||||
readonly COURSE_ID: number;
|
||||
readonly CM_ID: number;
|
||||
|
||||
selectedGroup?: number;
|
||||
identifiable?: AddonModFeedbackWSAttempt[];
|
||||
identifiableTotal?: number;
|
||||
anonymous?: AddonModFeedbackWSAnonAttempt[];
|
||||
anonymousTotal?: number;
|
||||
groupInfo?: CoreGroupInfo;
|
||||
|
||||
protected feedback?: AddonModFeedbackWSFeedback;
|
||||
|
||||
constructor(courseId: number, cmId: number) {
|
||||
super();
|
||||
|
||||
this.COURSE_ID = courseId;
|
||||
this.CM_ID = cmId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
getItemPath(attempt: AddonModFeedbackAttemptItem): string {
|
||||
return attempt.id.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
getPagesLoaded(): number {
|
||||
if (!this.identifiable || !this.anonymous) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const pageLength = this.getPageLength();
|
||||
|
||||
return Math.ceil(Math.max(this.anonymous.length, this.identifiable.length) / pageLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to infer AddonModFeedbackWSAttempt objects.
|
||||
*
|
||||
* @param discussion Item to check.
|
||||
* @return Whether the item is an identifieable attempt.
|
||||
*/
|
||||
isIdentifiableAttempt(attempt: AddonModFeedbackAttemptItem): attempt is AddonModFeedbackWSAttempt {
|
||||
return 'fullname' in attempt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to infer AddonModFeedbackWSAnonAttempt objects.
|
||||
*
|
||||
* @param discussion Item to check.
|
||||
* @return Whether the item is an anonymous attempt.
|
||||
*/
|
||||
isAnonymousAttempt(attempt: AddonModFeedbackAttemptItem): attempt is AddonModFeedbackWSAnonAttempt {
|
||||
return 'number' in attempt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate feedback cache.
|
||||
*/
|
||||
async invalidateCache(): Promise<void> {
|
||||
await Promise.all([
|
||||
CoreGroups.invalidateActivityGroupInfo(this.CM_ID),
|
||||
this.feedback && AddonModFeedback.invalidateResponsesAnalysisData(this.feedback.id),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load feedback.
|
||||
*/
|
||||
async loadFeedback(): Promise<void> {
|
||||
this.feedback = await AddonModFeedback.getFeedback(this.COURSE_ID, this.CM_ID);
|
||||
this.groupInfo = await CoreGroups.getActivityGroupInfo(this.CM_ID);
|
||||
|
||||
this.selectedGroup = CoreGroups.validateGroupId(this.selectedGroup, this.groupInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected getPageLength(): number {
|
||||
return AddonModFeedbackProvider.PER_PAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected async loadPageItems(page: number): Promise<{ items: AddonModFeedbackAttemptItem[]; hasMoreItems: boolean }> {
|
||||
if (!this.feedback) {
|
||||
throw new Error('Can\'t load attempts without feeback');
|
||||
}
|
||||
|
||||
const result = await AddonModFeedbackHelper.getResponsesAnalysis(this.feedback.id, {
|
||||
page,
|
||||
groupId: this.selectedGroup,
|
||||
cmId: this.CM_ID,
|
||||
});
|
||||
|
||||
if (page === 0) {
|
||||
this.identifiableTotal = result.totalattempts;
|
||||
this.anonymousTotal = result.totalanonattempts;
|
||||
}
|
||||
|
||||
const totalItemsLoaded = this.getPageLength() * (page + 1);
|
||||
const pageAttempts: AddonModFeedbackAttemptItem[] = [
|
||||
...result.attempts,
|
||||
...result.anonattempts,
|
||||
];
|
||||
|
||||
return {
|
||||
items: pageAttempts,
|
||||
hasMoreItems: result.totalattempts > totalItemsLoaded || result.totalanonattempts > totalItemsLoaded,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected setItems(attempts: AddonModFeedbackAttemptItem[], hasMoreItems: boolean): void {
|
||||
this.identifiable = attempts.filter(this.isIdentifiableAttempt);
|
||||
this.anonymous = attempts.filter(this.isAnonymousAttempt);
|
||||
|
||||
super.setItems((this.identifiable as AddonModFeedbackAttemptItem[]).concat(this.anonymous), hasMoreItems);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Type of items that can be held in the source.
|
||||
*/
|
||||
export type AddonModFeedbackAttemptItem = AddonModFeedbackWSAttempt | AddonModFeedbackWSAnonAttempt;
|
|
@ -12,45 +12,47 @@
|
|||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<core-loading [hideUntil]="loaded">
|
||||
<ion-list class="ion-no-margin" *ngIf="attempt || anonAttempt">
|
||||
<ion-item *ngIf="attempt" class="ion-text-wrap" core-user-link [userId]="attempt.userid"
|
||||
[attr.aria-label]=" 'core.user.viewprofile' | translate" [courseId]="attempt.courseid">
|
||||
<core-user-avatar [user]="attempt" slot="start"></core-user-avatar>
|
||||
<ion-label>
|
||||
<h2>{{attempt.fullname}}</h2>
|
||||
<p *ngIf="attempt.timemodified">{{attempt.timemodified * 1000 | coreFormatDate }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<core-swipe-navigation [manager]="attempts">
|
||||
<core-loading [hideUntil]="loaded">
|
||||
<ion-list class="ion-no-margin" *ngIf="attempt || anonAttempt">
|
||||
<ion-item *ngIf="attempt" class="ion-text-wrap" core-user-link [userId]="attempt.userid"
|
||||
[attr.aria-label]=" 'core.user.viewprofile' | translate" [courseId]="attempt.courseid">
|
||||
<core-user-avatar [user]="attempt" slot="start"></core-user-avatar>
|
||||
<ion-label>
|
||||
<h2>{{attempt.fullname}}</h2>
|
||||
<p *ngIf="attempt.timemodified">{{attempt.timemodified * 1000 | coreFormatDate }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item class="ion-text-wrap" *ngIf="anonAttempt">
|
||||
<ion-label>
|
||||
<h2>
|
||||
{{ 'addon.mod_feedback.response_nr' |translate }}: {{anonAttempt.number}}
|
||||
({{ 'addon.mod_feedback.anonymous' |translate }})
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ng-container *ngIf="items && items.length">
|
||||
<ng-container *ngFor="let item of items">
|
||||
<core-spacer *ngIf="item.typ == 'pagebreak'"></core-spacer>
|
||||
<ion-item class="ion-text-wrap" *ngIf="item.typ != 'pagebreak'" [color]="item.dependitem > 0 ? 'light' : ''">
|
||||
<ion-label>
|
||||
<h2 *ngIf="item.name" [core-mark-required]="item.required">
|
||||
<span *ngIf="feedback!.autonumbering && item.itemnumber">{{item.itemnumber}}. </span>
|
||||
<core-format-text [component]="component" [componentId]="cmId" [text]="item.name" contextLevel="module"
|
||||
[contextInstanceId]="cmId" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
</h2>
|
||||
<p *ngIf="item.submittedValue">
|
||||
<core-format-text [component]="component" [componentId]="cmId" [text]="item.submittedValue"
|
||||
contextLevel="module" [contextInstanceId]="cmId" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="anonAttempt">
|
||||
<ion-label>
|
||||
<h2>
|
||||
{{ 'addon.mod_feedback.response_nr' |translate }}: {{anonAttempt.number}}
|
||||
({{ 'addon.mod_feedback.anonymous' |translate }})
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ng-container *ngIf="items && items.length">
|
||||
<ng-container *ngFor="let item of items">
|
||||
<core-spacer *ngIf="item.typ == 'pagebreak'"></core-spacer>
|
||||
<ion-item class="ion-text-wrap" *ngIf="item.typ != 'pagebreak'" [color]="item.dependitem > 0 ? 'light' : ''">
|
||||
<ion-label>
|
||||
<h2 *ngIf="item.name" [core-mark-required]="item.required">
|
||||
<span *ngIf="feedback!.autonumbering && item.itemnumber">{{item.itemnumber}}. </span>
|
||||
<core-format-text [component]="component" [componentId]="cmId" [text]="item.name" contextLevel="module"
|
||||
[contextInstanceId]="cmId" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
</h2>
|
||||
<p *ngIf="item.submittedValue">
|
||||
<core-format-text [component]="component" [componentId]="cmId" [text]="item.submittedValue"
|
||||
contextLevel="module" [contextInstanceId]="cmId" [courseId]="courseId">
|
||||
</core-format-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ion-list>
|
||||
</core-loading>
|
||||
</ion-list>
|
||||
</core-loading>
|
||||
</core-swipe-navigation>
|
||||
</ion-content>
|
||||
|
|
|
@ -12,10 +12,14 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot } from '@angular/router';
|
||||
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker';
|
||||
import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreTextUtils } from '@services/utils/text';
|
||||
import { AddonModFeedbackAttemptsSource } from '../../classes/feedback-attempts-source';
|
||||
import {
|
||||
AddonModFeedback,
|
||||
AddonModFeedbackProvider,
|
||||
|
@ -32,7 +36,7 @@ import { AddonModFeedbackFormItem, AddonModFeedbackHelper } from '../../services
|
|||
selector: 'page-addon-mod-feedback-attempt',
|
||||
templateUrl: 'attempt.html',
|
||||
})
|
||||
export class AddonModFeedbackAttemptPage implements OnInit {
|
||||
export class AddonModFeedbackAttemptPage implements OnInit, OnDestroy {
|
||||
|
||||
protected attemptId!: number;
|
||||
|
||||
|
@ -40,6 +44,7 @@ export class AddonModFeedbackAttemptPage implements OnInit {
|
|||
courseId!: number;
|
||||
feedback?: AddonModFeedbackWSFeedback;
|
||||
attempt?: AddonModFeedbackWSAttempt;
|
||||
attempts?: AddonModFeedbackAttemptsSwipeManager;
|
||||
anonAttempt?: AddonModFeedbackWSAnonAttempt;
|
||||
items: AddonModFeedbackAttemptItem[] = [];
|
||||
component = AddonModFeedbackProvider.COMPONENT;
|
||||
|
@ -53,6 +58,15 @@ export class AddonModFeedbackAttemptPage implements OnInit {
|
|||
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
|
||||
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
|
||||
this.attemptId = CoreNavigator.getRequiredRouteNumberParam('attemptId');
|
||||
|
||||
const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(
|
||||
AddonModFeedbackAttemptsSource,
|
||||
[this.courseId, this.cmId],
|
||||
);
|
||||
|
||||
this.attempts = new AddonModFeedbackAttemptsSwipeManager(source);
|
||||
|
||||
this.attempts.start();
|
||||
} catch (error) {
|
||||
CoreDomUtils.showErrorModal(error);
|
||||
|
||||
|
@ -64,6 +78,13 @@ export class AddonModFeedbackAttemptPage implements OnInit {
|
|||
this.fetchData();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.attempts?.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the data required for the view.
|
||||
*
|
||||
|
@ -131,3 +152,17 @@ export class AddonModFeedbackAttemptPage implements OnInit {
|
|||
type AddonModFeedbackAttemptItem = AddonModFeedbackFormItem & {
|
||||
submittedValue?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to manage swiping within a collection of discussions.
|
||||
*/
|
||||
class AddonModFeedbackAttemptsSwipeManager extends CoreSwipeNavigationItemsManager {
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null {
|
||||
return route.params.attemptId;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -10,77 +10,57 @@
|
|||
</ion-header>
|
||||
<ion-content>
|
||||
<core-split-view>
|
||||
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshFeedback($event.target)">
|
||||
<ion-refresher slot="fixed" [disabled]="!attempts || !attempts.loaded" (ionRefresh)="refreshFeedback($event.target)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
<core-loading [hideUntil]="loaded">
|
||||
<core-loading [hideUntil]="attempts && attempts.loaded">
|
||||
<ion-list class="ion-no-margin">
|
||||
<ion-item class="ion-text-wrap" *ngIf="groupInfo && (groupInfo.separateGroups || groupInfo.visibleGroups)">
|
||||
<ion-label id="addon-feedback-groupslabel">
|
||||
<ng-container *ngIf="groupInfo.separateGroups">{{'core.groupsseparate' | translate }}</ng-container>
|
||||
<ng-container *ngIf="groupInfo.visibleGroups">{{'core.groupsvisible' | translate }}</ng-container>
|
||||
</ion-label>
|
||||
<ion-select [(ngModel)]="selectedGroup" (ionChange)="loadAttempts(selectedGroup)"
|
||||
aria-labelledby="addon-feedback-groupslabel" interface="action-sheet"
|
||||
[interfaceOptions]="{header: 'core.group' | translate}">
|
||||
<ion-select [(ngModel)]="selectedGroup" (ionChange)="reloadAttempts()" aria-labelledby="addon-feedback-groupslabel"
|
||||
interface="action-sheet" [interfaceOptions]="{header: 'core.group' | translate}">
|
||||
<ion-select-option *ngFor="let groupOpt of groupInfo.groups" [value]="groupOpt.id">
|
||||
{{groupOpt.name}}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
|
||||
<ng-container *ngIf="attempts.identifiable.total > 0">
|
||||
<ng-container *ngIf="identifiableAttemptsTotal > 0">
|
||||
<ion-item-divider>
|
||||
<ion-label>
|
||||
<h2>{{ 'addon.mod_feedback.non_anonymous_entries' | translate : {$a: attempts.identifiable.total } }}
|
||||
</h2>
|
||||
<h2>{{ 'addon.mod_feedback.non_anonymous_entries' | translate : {$a: identifiableAttemptsTotal } }}</h2>
|
||||
</ion-label>
|
||||
</ion-item-divider>
|
||||
<ion-item *ngFor="let attempt of attempts.identifiable.items" class="ion-text-wrap" button detail="true"
|
||||
(click)="attempts.select(attempt)" [attr.aria-current]="attempts.getItemAriaCurrent(attempt)">
|
||||
<core-user-avatar [user]="attempt" slot="start"></core-user-avatar>
|
||||
<ion-item *ngFor="let attempt of identifiableAttempts" class="ion-text-wrap" button detail="true"
|
||||
(click)="attempts?.select(attempt)" [attr.aria-current]="attempts?.getItemAriaCurrent(attempt)">
|
||||
<core-user-avatar [user]="attempt" [linkProfile]="false" slot="start"></core-user-avatar>
|
||||
<ion-label>
|
||||
<p class="item-heading">{{ attempt.fullname }}</p>
|
||||
<p *ngIf="attempt.timemodified">{{attempt.timemodified * 1000 | coreFormatDate }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- Button and spinner to show more attempts. -->
|
||||
<ion-button *ngIf="attempts.identifiable.canLoadMore && !loadingMore" class="ion-margin" expand="block"
|
||||
(click)="loadAttempts()">
|
||||
{{ 'core.loadmore' | translate }}
|
||||
</ion-button>
|
||||
<ion-item *ngIf="attempts.identifiable.canLoadMore && loadingMore" class="ion-text-center">
|
||||
<ion-label>
|
||||
<ion-spinner [attr.aria-label]="'core.loading' | translate"></ion-spinner>
|
||||
<p *ngIf="attempt.timemodified">{{ attempt.timemodified * 1000 | coreFormatDate }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="attempts.anonymous.total > 0">
|
||||
<ng-container *ngIf="identifiableAttemptsTotal === identifiableAttempts.length && anonymousAttemptsTotal > 0">
|
||||
<ion-item-divider>
|
||||
<ion-label>
|
||||
<h2>{{ 'addon.mod_feedback.anonymous_entries' |translate : {$a: attempts.anonymous.total } }}</h2>
|
||||
<h2>{{ 'addon.mod_feedback.anonymous_entries' | translate : {$a: anonymousAttemptsTotal } }}</h2>
|
||||
</ion-label>
|
||||
</ion-item-divider>
|
||||
<ion-item *ngFor="let attempt of attempts.anonymous.items" class="ion-text-wrap" button detail="true"
|
||||
(click)="attempts.select(attempt)" [attr.aria-current]="attempts.getItemAriaCurrent(attempt)">
|
||||
<ion-item *ngFor="let attempt of anonymousAttempts" class="ion-text-wrap" button detail="true"
|
||||
(click)="attempts?.select(attempt)" [attr.aria-current]="attempts?.getItemAriaCurrent(attempt)">
|
||||
<ion-label>
|
||||
<h2>{{ 'addon.mod_feedback.response_nr' |translate }}: {{attempt.number}}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- Button and spinner to show more attempts. -->
|
||||
<ion-button *ngIf="attempts.anonymous.canLoadMore && !loadingMore" class="ion-margin" expand="block"
|
||||
(click)="loadAttempts()">
|
||||
{{ 'core.loadmore' | translate }}
|
||||
</ion-button>
|
||||
<ion-item *ngIf="attempts.anonymous.canLoadMore && loadingMore" class="ion-text-center">
|
||||
<ion-label>
|
||||
<ion-spinner [attr.aria-label]="'core.loading' | translate"></ion-spinner>
|
||||
<h2>{{ 'addon.mod_feedback.response_nr' | translate }}: {{attempt.number}}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
|
||||
<core-infinite-loading [enabled]="attempts && attempts.loaded && !attempts.completed" [error]="fetchFailed"
|
||||
(action)="fetchMoreAttempts($event)">
|
||||
</core-infinite-loading>
|
||||
</ion-list>
|
||||
</core-loading>
|
||||
</core-split-view>
|
||||
|
|
|
@ -12,22 +12,19 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { AfterViewInit, Component, ViewChild } from '@angular/core';
|
||||
import { AfterViewInit, Component, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { CorePageItemsListManager } from '@classes/page-items-list-manager';
|
||||
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker';
|
||||
import { CoreListItemsManager } from '@classes/items-management/list-items-manager';
|
||||
import { CorePromisedValue } from '@classes/promised-value';
|
||||
import { CoreSplitViewComponent } from '@components/split-view/split-view';
|
||||
import { IonRefresher } from '@ionic/angular';
|
||||
import { CoreGroupInfo, CoreGroups } from '@services/groups';
|
||||
import { CoreGroupInfo } from '@services/groups';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import {
|
||||
AddonModFeedback,
|
||||
AddonModFeedbackWSAnonAttempt,
|
||||
AddonModFeedbackWSAttempt,
|
||||
AddonModFeedbackWSFeedback,
|
||||
} from '../../services/feedback';
|
||||
import { AddonModFeedbackHelper, AddonModFeedbackResponsesAnalysis } from '../../services/feedback-helper';
|
||||
import { AddonModFeedbackAttemptItem, AddonModFeedbackAttemptsSource } from '../../classes/feedback-attempts-source';
|
||||
import { AddonModFeedbackWSAnonAttempt, AddonModFeedbackWSAttempt } from '../../services/feedback';
|
||||
|
||||
/**
|
||||
* Page that displays feedback attempts.
|
||||
|
@ -36,27 +33,52 @@ import { AddonModFeedbackHelper, AddonModFeedbackResponsesAnalysis } from '../..
|
|||
selector: 'page-addon-mod-feedback-attempts',
|
||||
templateUrl: 'attempts.html',
|
||||
})
|
||||
export class AddonModFeedbackAttemptsPage implements AfterViewInit {
|
||||
export class AddonModFeedbackAttemptsPage implements AfterViewInit, OnDestroy {
|
||||
|
||||
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
|
||||
|
||||
protected cmId!: number;
|
||||
protected courseId!: number;
|
||||
protected page = 0;
|
||||
protected feedback?: AddonModFeedbackWSFeedback;
|
||||
promisedAttempts: CorePromisedValue<CoreListItemsManager<AddonModFeedbackAttemptItem, AddonModFeedbackAttemptsSource>>;
|
||||
fetchFailed = false;
|
||||
|
||||
attempts: AddonModFeedbackAttemptsManager;
|
||||
selectedGroup!: number;
|
||||
groupInfo?: CoreGroupInfo;
|
||||
loaded = false;
|
||||
loadingMore = false;
|
||||
constructor(protected route: ActivatedRoute) {
|
||||
this.promisedAttempts = new CorePromisedValue();
|
||||
}
|
||||
|
||||
constructor(
|
||||
route: ActivatedRoute,
|
||||
) {
|
||||
this.attempts = new AddonModFeedbackAttemptsManager(
|
||||
route.component,
|
||||
);
|
||||
get attempts(): CoreListItemsManager<AddonModFeedbackAttemptItem, AddonModFeedbackAttemptsSource> | null {
|
||||
return this.promisedAttempts.value;
|
||||
}
|
||||
|
||||
get groupInfo(): CoreGroupInfo | undefined {
|
||||
return this.attempts?.getSource().groupInfo;
|
||||
}
|
||||
|
||||
get selectedGroup(): number | undefined {
|
||||
return this.attempts?.getSource().selectedGroup;
|
||||
}
|
||||
|
||||
set selectedGroup(group: number | undefined) {
|
||||
if (!this.attempts) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.attempts.getSource().selectedGroup = group;
|
||||
this.attempts.getSource().setDirty(true);
|
||||
}
|
||||
|
||||
get identifiableAttempts(): AddonModFeedbackWSAttempt[] {
|
||||
return this.attempts?.getSource().identifiable ?? [];
|
||||
}
|
||||
|
||||
get identifiableAttemptsTotal(): number {
|
||||
return this.attempts?.getSource().identifiableTotal ?? 0;
|
||||
}
|
||||
|
||||
get anonymousAttempts(): AddonModFeedbackWSAnonAttempt[] {
|
||||
return this.attempts?.getSource().anonymous ?? [];
|
||||
}
|
||||
|
||||
get anonymousAttemptsTotal(): number {
|
||||
return this.attempts?.getSource().anonymousTotal ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -64,9 +86,16 @@ export class AddonModFeedbackAttemptsPage implements AfterViewInit {
|
|||
*/
|
||||
async ngAfterViewInit(): Promise<void> {
|
||||
try {
|
||||
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
|
||||
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
|
||||
this.selectedGroup = CoreNavigator.getRouteNumberParam('group') || 0;
|
||||
const cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
|
||||
const courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
|
||||
const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(
|
||||
AddonModFeedbackAttemptsSource,
|
||||
[courseId, cmId],
|
||||
);
|
||||
|
||||
source.selectedGroup = CoreNavigator.getRouteNumberParam('group') || 0;
|
||||
|
||||
this.promisedAttempts.resolve(new CoreListItemsManager(source, this.route.component));
|
||||
} catch (error) {
|
||||
CoreDomUtils.showErrorModal(error);
|
||||
|
||||
|
@ -75,79 +104,47 @@ export class AddonModFeedbackAttemptsPage implements AfterViewInit {
|
|||
return;
|
||||
}
|
||||
|
||||
await this.fetchData();
|
||||
|
||||
this.attempts.start(this.splitView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the data required for the view.
|
||||
*
|
||||
* @param refresh Empty events array first.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async fetchData(refresh: boolean = false): Promise<void> {
|
||||
this.page = 0;
|
||||
this.attempts.resetItems();
|
||||
const attempts = await this.promisedAttempts;
|
||||
|
||||
try {
|
||||
this.feedback = await AddonModFeedback.getFeedback(this.courseId, this.cmId);
|
||||
this.fetchFailed = false;
|
||||
|
||||
this.groupInfo = await CoreGroups.getActivityGroupInfo(this.cmId);
|
||||
|
||||
this.selectedGroup = CoreGroups.validateGroupId(this.selectedGroup, this.groupInfo);
|
||||
|
||||
await this.loadGroupAttempts(this.selectedGroup);
|
||||
await attempts.getSource().loadFeedback();
|
||||
await attempts.load();
|
||||
} catch (error) {
|
||||
CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true);
|
||||
this.fetchFailed = true;
|
||||
|
||||
if (!refresh) {
|
||||
// Some call failed on first fetch, go back.
|
||||
CoreNavigator.back();
|
||||
}
|
||||
CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true);
|
||||
}
|
||||
|
||||
await attempts.start(this.splitView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Group attempts.
|
||||
*
|
||||
* @param groupId If defined it will change group if not, it will load more attempts for the same group.
|
||||
* @return Resolved with the attempts loaded.
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected async loadGroupAttempts(groupId?: number): Promise<void> {
|
||||
if (groupId === undefined) {
|
||||
this.page++;
|
||||
this.loadingMore = true;
|
||||
} else {
|
||||
this.selectedGroup = groupId;
|
||||
this.page = 0;
|
||||
this.attempts.resetItems();
|
||||
}
|
||||
ngOnDestroy(): void {
|
||||
this.attempts?.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch more attempts, if any.
|
||||
*
|
||||
* @param infiniteComplete Complete callback for infinite loader.
|
||||
*/
|
||||
async fetchMoreAttempts(infiniteComplete?: () => void): Promise<void> {
|
||||
const attempts = await this.promisedAttempts;
|
||||
|
||||
try {
|
||||
const attempts = await AddonModFeedbackHelper.getResponsesAnalysis(this.feedback!.id, {
|
||||
groupId: this.selectedGroup,
|
||||
page: this.page,
|
||||
cmId: this.cmId,
|
||||
});
|
||||
this.fetchFailed = false;
|
||||
|
||||
this.attempts.setAttempts(attempts);
|
||||
await attempts.load();
|
||||
} catch (error) {
|
||||
this.fetchFailed = true;
|
||||
|
||||
CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true);
|
||||
} finally {
|
||||
this.loadingMore = false;
|
||||
this.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change selected group or load more attempts.
|
||||
*
|
||||
* @param groupId Group ID selected. If not defined, it will load more attempts.
|
||||
*/
|
||||
async loadAttempts(groupId?: number): Promise<void> {
|
||||
try {
|
||||
await this.loadGroupAttempts(groupId);
|
||||
} catch (error) {
|
||||
CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true);
|
||||
infiniteComplete && infiniteComplete();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -157,100 +154,30 @@ export class AddonModFeedbackAttemptsPage implements AfterViewInit {
|
|||
* @param refresher Refresher.
|
||||
*/
|
||||
async refreshFeedback(refresher: IonRefresher): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
promises.push(CoreGroups.invalidateActivityGroupInfo(this.cmId));
|
||||
if (this.feedback) {
|
||||
promises.push(AddonModFeedback.invalidateResponsesAnalysisData(this.feedback.id));
|
||||
}
|
||||
const attempts = await this.promisedAttempts;
|
||||
|
||||
try {
|
||||
await CoreUtils.ignoreErrors(Promise.all(promises));
|
||||
this.fetchFailed = false;
|
||||
|
||||
await this.fetchData(true);
|
||||
await CoreUtils.ignoreErrors(attempts.getSource().invalidateCache());
|
||||
await attempts.getSource().loadFeedback();
|
||||
await attempts.reload();
|
||||
} catch (error) {
|
||||
this.fetchFailed = true;
|
||||
|
||||
CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true);
|
||||
} finally {
|
||||
refresher.complete();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Type of items that can be held by the entries manager.
|
||||
*/
|
||||
type EntryItem = AddonModFeedbackWSAttempt | AddonModFeedbackWSAnonAttempt;
|
||||
|
||||
/**
|
||||
* Entries manager.
|
||||
*/
|
||||
class AddonModFeedbackAttemptsManager extends CorePageItemsListManager<EntryItem> {
|
||||
|
||||
identifiable: AddonModFeedbackIdentifiableAttempts = {
|
||||
items: [],
|
||||
total: 0,
|
||||
canLoadMore: false,
|
||||
};
|
||||
|
||||
anonymous: AddonModFeedbackAnonymousAttempts = {
|
||||
items: [],
|
||||
total: 0,
|
||||
canLoadMore: false,
|
||||
};
|
||||
|
||||
constructor(pageComponent: unknown) {
|
||||
super(pageComponent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update attempts.
|
||||
*
|
||||
* @param attempts Attempts.
|
||||
* Reload attempts list.
|
||||
*/
|
||||
setAttempts(attempts: AddonModFeedbackResponsesAnalysis): void {
|
||||
this.identifiable.total = attempts.totalattempts;
|
||||
this.anonymous.total = attempts.totalanonattempts;
|
||||
async reloadAttempts(): Promise<void> {
|
||||
const attempts = await this.promisedAttempts;
|
||||
|
||||
if (this.anonymous.items.length < attempts.totalanonattempts) {
|
||||
this.anonymous.items = this.anonymous.items.concat(attempts.anonattempts);
|
||||
}
|
||||
if (this.identifiable.items.length < attempts.totalattempts) {
|
||||
this.identifiable.items = this.identifiable.items.concat(attempts.attempts);
|
||||
}
|
||||
|
||||
this.anonymous.canLoadMore = this.anonymous.items.length < attempts.totalanonattempts;
|
||||
this.identifiable.canLoadMore = this.identifiable.items.length < attempts.totalattempts;
|
||||
|
||||
this.setItems((<EntryItem[]> this.identifiable.items).concat(this.anonymous.items));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
resetItems(): void {
|
||||
super.resetItems();
|
||||
this.identifiable.total = 0;
|
||||
this.identifiable.items = [];
|
||||
this.anonymous.total = 0;
|
||||
this.anonymous.items = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected getItemPath(entry: EntryItem): string {
|
||||
return entry.id.toString();
|
||||
await attempts.reload();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type AddonModFeedbackIdentifiableAttempts = {
|
||||
items: AddonModFeedbackWSAttempt[];
|
||||
total: number;
|
||||
canLoadMore: boolean;
|
||||
};
|
||||
|
||||
type AddonModFeedbackAnonymousAttempts = {
|
||||
items: AddonModFeedbackWSAnonAttempt[];
|
||||
total: number;
|
||||
canLoadMore: boolean;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,147 @@
|
|||
// (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.
|
||||
|
||||
/**
|
||||
* Promise wrapper to expose result synchronously.
|
||||
*/
|
||||
export class CorePromisedValue<T = unknown> implements Promise<T> {
|
||||
|
||||
/**
|
||||
* Wrap an existing promise.
|
||||
*
|
||||
* @param promise Promise.
|
||||
* @returns Promised value.
|
||||
*/
|
||||
static from<T>(promise: Promise<T>): CorePromisedValue<T> {
|
||||
const promisedValue = new CorePromisedValue<T>();
|
||||
|
||||
promise
|
||||
.then(promisedValue.resolve.bind(promisedValue))
|
||||
.catch(promisedValue.reject.bind(promisedValue));
|
||||
|
||||
return promisedValue;
|
||||
}
|
||||
|
||||
private _resolvedValue?: T;
|
||||
private _rejectedReason?: Error;
|
||||
declare private promise: Promise<T>;
|
||||
declare private _resolve: (result: T) => void;
|
||||
declare private _reject: (error?: Error) => void;
|
||||
|
||||
constructor() {
|
||||
this.initPromise();
|
||||
}
|
||||
|
||||
[Symbol.toStringTag]: string;
|
||||
|
||||
get value(): T | null {
|
||||
return this._resolvedValue ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the promise resolved successfully.
|
||||
*
|
||||
* @return Whether the promise resolved successfuly.
|
||||
*/
|
||||
isResolved(): this is { value: T } {
|
||||
return '_resolvedValue' in this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the promise was rejected.
|
||||
*
|
||||
* @return Whether the promise was rejected.
|
||||
*/
|
||||
isRejected(): boolean {
|
||||
return '_rejectedReason' in this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the promise is settled.
|
||||
*
|
||||
* @returns Whether the promise is settled.
|
||||
*/
|
||||
isSettled(): boolean {
|
||||
return this.isResolved() || this.isRejected();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
then<TResult1 = T, TResult2 = never>(
|
||||
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
|
||||
onRejected?: ((reason: Error) => TResult2 | PromiseLike<TResult2>) | undefined | null,
|
||||
): Promise<TResult1 | TResult2> {
|
||||
return this.promise.then(onFulfilled, onRejected);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
catch<TResult = never>(
|
||||
onRejected?: ((reason: Error) => TResult | PromiseLike<TResult>) | undefined | null,
|
||||
): Promise<T | TResult> {
|
||||
return this.promise.catch(onRejected);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
finally(onFinally?: (() => void) | null): Promise<T> {
|
||||
return this.promise.finally(onFinally);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the promise.
|
||||
*
|
||||
* @param value Promise result.
|
||||
*/
|
||||
resolve(value: T): void {
|
||||
if (this.isSettled()) {
|
||||
delete this._rejectedReason;
|
||||
|
||||
this.initPromise();
|
||||
}
|
||||
|
||||
this._resolvedValue = value;
|
||||
this._resolve(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the promise.
|
||||
*
|
||||
* @param value Rejection reason.
|
||||
*/
|
||||
reject(reason?: Error): void {
|
||||
if (this.isSettled()) {
|
||||
delete this._resolvedValue;
|
||||
|
||||
this.initPromise();
|
||||
}
|
||||
|
||||
this._rejectedReason = reason;
|
||||
this._reject(reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the promise and the callbacks.
|
||||
*/
|
||||
private initPromise(): void {
|
||||
this.promise = new Promise((resolve, reject) => {
|
||||
this._resolve = resolve;
|
||||
this._reject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
// (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 { CorePromisedValue } from '../promised-value';
|
||||
|
||||
describe('PromisedValue', () => {
|
||||
|
||||
it('works like a promise', async () => {
|
||||
const promisedString = new CorePromisedValue<string>();
|
||||
expect(promisedString.value).toBe(null);
|
||||
expect(promisedString.isResolved()).toBe(false);
|
||||
|
||||
promisedString.resolve('foo');
|
||||
expect(promisedString.isResolved()).toBe(true);
|
||||
expect(promisedString.value).toBe('foo');
|
||||
|
||||
const resolvedValue = await promisedString;
|
||||
expect(resolvedValue).toBe('foo');
|
||||
});
|
||||
|
||||
it('can update values', async () => {
|
||||
const promisedString = new CorePromisedValue<string>();
|
||||
promisedString.resolve('foo');
|
||||
promisedString.resolve('bar');
|
||||
|
||||
expect(promisedString.isResolved()).toBe(true);
|
||||
expect(promisedString.value).toBe('bar');
|
||||
|
||||
const resolvedValue = await promisedString;
|
||||
expect(resolvedValue).toBe('bar');
|
||||
});
|
||||
|
||||
});
|
Loading…
Reference in New Issue