forked from EVOgeek/Vmeda.Online
		
	Merge pull request #3012 from NoelDeMartin/MOBILE-3926
MOBILE-3926: Add swipe navigation to pages with split-view
This commit is contained in:
		
						commit
						ad6c7367ff
					
				
							
								
								
									
										60
									
								
								src/addons/badges/classes/user-badges-source.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/addons/badges/classes/user-badges-source.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,60 @@ | |||||||
|  | // (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 { Params } from '@angular/router'; | ||||||
|  | import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||||
|  | import { AddonBadges, AddonBadgesUserBadge } from '../services/badges'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Provides a collection of user badges. | ||||||
|  |  */ | ||||||
|  | export class AddonBadgesUserBadgesSource extends CoreItemsManagerSource<AddonBadgesUserBadge> { | ||||||
|  | 
 | ||||||
|  |     readonly COURSE_ID: number; | ||||||
|  |     readonly USER_ID: number; | ||||||
|  | 
 | ||||||
|  |     constructor(courseId: number, userId: number) { | ||||||
|  |         super(); | ||||||
|  | 
 | ||||||
|  |         this.COURSE_ID = courseId; | ||||||
|  |         this.USER_ID = userId; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     getItemPath(badge: AddonBadgesUserBadge): string { | ||||||
|  |         return badge.uniquehash; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     getItemQueryParams(): Params { | ||||||
|  |         return { | ||||||
|  |             courseId: this.COURSE_ID, | ||||||
|  |             userId: this.USER_ID, | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     protected async loadPageItems(): Promise<{ items: AddonBadgesUserBadge[] }> { | ||||||
|  |         const badges = await AddonBadges.getUserBadges(this.COURSE_ID, this.USER_ID); | ||||||
|  | 
 | ||||||
|  |         return { items: badges }; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -10,239 +10,241 @@ | |||||||
|     </ion-toolbar> |     </ion-toolbar> | ||||||
| </ion-header> | </ion-header> | ||||||
| <ion-content> | <ion-content> | ||||||
|     <ion-refresher slot="fixed" [disabled]="!badgeLoaded" (ionRefresh)="refreshBadges($event.target)"> |     <core-swipe-navigation [manager]="badges"> | ||||||
|         <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> |         <ion-refresher slot="fixed" [disabled]="!badgeLoaded" (ionRefresh)="refreshBadges($event.target)"> | ||||||
|     </ion-refresher> |             <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||||
|     <core-loading [hideUntil]="badgeLoaded"> |         </ion-refresher> | ||||||
|         <ion-item-group *ngIf="badge"> |         <core-loading [hideUntil]="badgeLoaded"> | ||||||
|             <ion-item class="ion-text-wrap ion-text-center"> |             <ion-item-group *ngIf="badge"> | ||||||
|                 <ion-label> |                 <ion-item class="ion-text-wrap ion-text-center"> | ||||||
|                     <img *ngIf="badge.badgeurl" class="large-avatar" [src]="badge.badgeurl" core-external-content [alt]="badge.name" /> |  | ||||||
|                     <ion-badge color="danger" *ngIf="badge.dateexpire && currentTime >= badge.dateexpire"> |  | ||||||
|                         {{ 'addon.badges.expired' | translate }} |  | ||||||
|                     </ion-badge> |  | ||||||
|                 </ion-label> |  | ||||||
|             </ion-item> |  | ||||||
|         </ion-item-group> |  | ||||||
| 
 |  | ||||||
|         <ion-item-group *ngIf="user"> |  | ||||||
|             <ion-item-divider> |  | ||||||
|                 <ion-label> |  | ||||||
|                     <h2>{{ 'addon.badges.recipientdetails' | translate}}</h2> |  | ||||||
|                 </ion-label> |  | ||||||
|             </ion-item-divider> |  | ||||||
|             <ion-item class="ion-text-wrap"> |  | ||||||
|                 <ion-label> |  | ||||||
|                     <h2>{{ 'core.name' | translate}}</h2> |  | ||||||
|                     <p>{{ user.fullname }}</p> |  | ||||||
|                 </ion-label> |  | ||||||
|             </ion-item> |  | ||||||
|         </ion-item-group> |  | ||||||
| 
 |  | ||||||
|         <ng-container *ngIf="badge"> |  | ||||||
|             <ion-item-group> |  | ||||||
|                 <ion-item-divider> |  | ||||||
|                     <ion-label> |                     <ion-label> | ||||||
|                         <h2>{{ 'addon.badges.issuerdetails' | translate}}</h2> |                         <img *ngIf="badge.badgeurl" class="large-avatar" [src]="badge.badgeurl" core-external-content [alt]="badge.name" /> | ||||||
|                     </ion-label> |                         <ion-badge color="danger" *ngIf="badge.dateexpire && currentTime >= badge.dateexpire"> | ||||||
|                 </ion-item-divider> |                             {{ 'addon.badges.expired' | translate }} | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.issuername"> |                         </ion-badge> | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.issuername' | translate}}</h2> |  | ||||||
|                         <p>{{ badge.issuername }}</p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.issuercontact"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.contact' | translate}}</h2> |  | ||||||
|                         <p><a href="mailto:{{badge.issuercontact}}" core-link auto-login="no" [showBrowserWarning]="false"> |  | ||||||
|                                 {{ badge.issuercontact }} |  | ||||||
|                             </a></p> |  | ||||||
|                     </ion-label> |                     </ion-label> | ||||||
|                 </ion-item> |                 </ion-item> | ||||||
|             </ion-item-group> |             </ion-item-group> | ||||||
| 
 | 
 | ||||||
|             <ion-item-group> |             <ion-item-group *ngIf="user"> | ||||||
|                 <ion-item-divider> |                 <ion-item-divider> | ||||||
|                     <ion-label> |                     <ion-label> | ||||||
|                         <h2>{{ 'addon.badges.badgedetails' | translate}}</h2> |                         <h2>{{ 'addon.badges.recipientdetails' | translate}}</h2> | ||||||
|                     </ion-label> |                     </ion-label> | ||||||
|                 </ion-item-divider> |                 </ion-item-divider> | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.name"> |                 <ion-item class="ion-text-wrap"> | ||||||
|                     <ion-label> |                     <ion-label> | ||||||
|                         <h2>{{ 'core.name' | translate}}</h2> |                         <h2>{{ 'core.name' | translate}}</h2> | ||||||
|                         <p>{{ badge.name }}</p> |                         <p>{{ user.fullname }}</p> | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.version"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.version' | translate}}</h2> |  | ||||||
|                         <p>{{ badge.version }}</p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.language"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.language' | translate}}</h2> |  | ||||||
|                         <p>{{ badge.language }}</p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.description"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'core.description' | translate}}</h2> |  | ||||||
|                         <p>{{ badge.description }}</p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.imageauthorname"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.imageauthorname' | translate}}</h2> |  | ||||||
|                         <p>{{ badge.imageauthorname }}</p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.imageauthoremail"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.imageauthoremail' | translate}}</h2> |  | ||||||
|                         <p><a href="mailto:{{badge.imageauthoremail}}" core-link auto-login="no" [showBrowserWarning]="false"> |  | ||||||
|                                 {{ badge.imageauthoremail }} |  | ||||||
|                             </a></p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.imageauthorurl"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.imageauthorurl' | translate}}</h2> |  | ||||||
|                         <p><a [href]="badge.imageauthorurl" core-link auto-login="no"> {{ badge.imageauthorurl }} </a></p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.imagecaption"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.imagecaption' | translate}}</h2> |  | ||||||
|                         <p>{{ badge.imagecaption }}</p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="course"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'core.course' | translate}}</h2> |  | ||||||
|                         <p> |  | ||||||
|                             <core-format-text [text]="course.fullname" contextLevel="course" [contextInstanceId]="courseId"> |  | ||||||
|                             </core-format-text> |  | ||||||
|                         </p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <!-- Criteria (not yet available) --> |  | ||||||
|             </ion-item-group> |  | ||||||
| 
 |  | ||||||
|             <ion-item-group> |  | ||||||
|                 <ion-item-divider> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.issuancedetails' | translate}}</h2> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item-divider> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.dateissued"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.dateawarded' | translate}}</h2> |  | ||||||
|                         <p>{{badge.dateissued * 1000 | coreFormatDate }}</p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.dateexpire"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.expirydate' | translate}}</h2> |  | ||||||
|                         <p> |  | ||||||
|                             {{ badge.dateexpire * 1000 | coreFormatDate }} |  | ||||||
|                             <span class="text-danger" *ngIf="currentTime >= badge.dateexpire"> |  | ||||||
|                                 {{ 'addon.badges.warnexpired' | translate }} |  | ||||||
|                             </span> |  | ||||||
|                         </p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <!-- Evidence (not yet available) --> |  | ||||||
|             </ion-item-group> |  | ||||||
| 
 |  | ||||||
|             <!-- Endorsement --> |  | ||||||
|             <ion-item-group *ngIf="badge.endorsement"> |  | ||||||
|                 <ion-item-divider> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.bendorsement' | translate}}</h2> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item-divider> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issuername"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.issuername' | translate}}</h2> |  | ||||||
|                         <p>{{ badge.endorsement.issuername }}</p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issueremail"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.issueremail' | translate}}</h2> |  | ||||||
|                         <p> |  | ||||||
|                             <a href="mailto:{{badge.endorsement.issueremail}}" core-link auto-login="no" [showBrowserWarning]="false"> |  | ||||||
|                                 {{ badge.endorsement.issueremail }} |  | ||||||
|                             </a> |  | ||||||
|                         </p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issuerurl"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.issuerurl' | translate}}</h2> |  | ||||||
|                         <p><a [href]="badge.endorsement.issuerurl" core-link auto-login="no"> {{ badge.endorsement.issuerurl }} </a></p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.dateissued"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.dateawarded' | translate}}</h2> |  | ||||||
|                         <p>{{ badge.endorsement.dateissued * 1000 | coreFormatDate }}</p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.claimid"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.claimid' | translate}}</h2> |  | ||||||
|                         <p><a [href]="badge.endorsement.claimid" core-link auto-login="no"> {{ badge.endorsement.claimid }} </a></p> |  | ||||||
|                     </ion-label> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.claimcomment"> |  | ||||||
|                     <ion-label> |  | ||||||
|                         <h2>{{ 'addon.badges.claimcomment' | translate}}</h2> |  | ||||||
|                         <p>{{ badge.endorsement.claimcomment }}</p> |  | ||||||
|                     </ion-label> |                     </ion-label> | ||||||
|                 </ion-item> |                 </ion-item> | ||||||
|             </ion-item-group> |             </ion-item-group> | ||||||
| 
 | 
 | ||||||
|             <!-- Related badges --> |             <ng-container *ngIf="badge"> | ||||||
|             <ion-item-group *ngIf="badge.relatedbadges"> |                 <ion-item-group> | ||||||
|                 <ion-item-divider> |                     <ion-item-divider> | ||||||
|                     <ion-label> |                         <ion-label> | ||||||
|                         <h2>{{ 'addon.badges.relatedbages' | translate}}</h2> |                             <h2>{{ 'addon.badges.issuerdetails' | translate}}</h2> | ||||||
|                     </ion-label> |                         </ion-label> | ||||||
|                 </ion-item-divider> |                     </ion-item-divider> | ||||||
|                 <ion-item class="ion-text-wrap" *ngFor="let relatedBadge of badge.relatedbadges"> |                     <ion-item class="ion-text-wrap" *ngIf="badge.issuername"> | ||||||
|                     <ion-label> |                         <ion-label> | ||||||
|                         <h2>{{ relatedBadge.name }}</h2> |                             <h2>{{ 'addon.badges.issuername' | translate}}</h2> | ||||||
|                     </ion-label> |                             <p>{{ badge.issuername }}</p> | ||||||
|                 </ion-item> |                         </ion-label> | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.relatedbadges.length == 0"> |                     </ion-item> | ||||||
|                     <ion-label> |                     <ion-item class="ion-text-wrap" *ngIf="badge.issuercontact"> | ||||||
|                         <h2>{{ 'addon.badges.norelated' | translate}}</h2> |                         <ion-label> | ||||||
|                     </ion-label> |                             <h2>{{ 'addon.badges.contact' | translate}}</h2> | ||||||
|                 </ion-item> |                             <p><a href="mailto:{{badge.issuercontact}}" core-link auto-login="no" [showBrowserWarning]="false"> | ||||||
|             </ion-item-group> |                                     {{ badge.issuercontact }} | ||||||
|  |                                 </a></p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                 </ion-item-group> | ||||||
| 
 | 
 | ||||||
|             <!-- Competencies alignment --> |                 <ion-item-group> | ||||||
|             <ion-item-group *ngIf="badge.alignment"> |                     <ion-item-divider> | ||||||
|                 <ion-item-divider> |                         <ion-label> | ||||||
|                     <ion-label> |                             <h2>{{ 'addon.badges.badgedetails' | translate}}</h2> | ||||||
|                         <h2>{{ 'addon.badges.alignment' | translate}}</h2> |                         </ion-label> | ||||||
|                     </ion-label> |                     </ion-item-divider> | ||||||
|                 </ion-item-divider> |                     <ion-item class="ion-text-wrap" *ngIf="badge.name"> | ||||||
|                 <ion-item class="ion-text-wrap" *ngFor="let alignment of badge.alignment" [href]="alignment.targeturl" core-link |                         <ion-label> | ||||||
|                     auto-login="no"> |                             <h2>{{ 'core.name' | translate}}</h2> | ||||||
|                     <ion-label> |                             <p>{{ badge.name }}</p> | ||||||
|                         <h2>{{ alignment.targetname }}</h2> |                         </ion-label> | ||||||
|                     </ion-label> |                     </ion-item> | ||||||
|                 </ion-item> |                     <ion-item class="ion-text-wrap" *ngIf="badge.version"> | ||||||
|                 <ion-item class="ion-text-wrap" *ngIf="badge.alignment.length == 0"> |                         <ion-label> | ||||||
|                     <ion-label> |                             <h2>{{ 'addon.badges.version' | translate}}</h2> | ||||||
|                         <h2>{{ 'addon.badges.noalignment' | translate}}</h2> |                             <p>{{ badge.version }}</p> | ||||||
|                     </ion-label> |                         </ion-label> | ||||||
|                 </ion-item> |                     </ion-item> | ||||||
|             </ion-item-group> |                     <ion-item class="ion-text-wrap" *ngIf="badge.language"> | ||||||
|         </ng-container> |                         <ion-label> | ||||||
|     </core-loading> |                             <h2>{{ 'addon.badges.language' | translate}}</h2> | ||||||
|  |                             <p>{{ badge.language }}</p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.description"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'core.description' | translate}}</h2> | ||||||
|  |                             <p>{{ badge.description }}</p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.imageauthorname"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.imageauthorname' | translate}}</h2> | ||||||
|  |                             <p>{{ badge.imageauthorname }}</p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.imageauthoremail"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.imageauthoremail' | translate}}</h2> | ||||||
|  |                             <p><a href="mailto:{{badge.imageauthoremail}}" core-link auto-login="no" [showBrowserWarning]="false"> | ||||||
|  |                                     {{ badge.imageauthoremail }} | ||||||
|  |                                 </a></p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.imageauthorurl"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.imageauthorurl' | translate}}</h2> | ||||||
|  |                             <p><a [href]="badge.imageauthorurl" core-link auto-login="no"> {{ badge.imageauthorurl }} </a></p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.imagecaption"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.imagecaption' | translate}}</h2> | ||||||
|  |                             <p>{{ badge.imagecaption }}</p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="course"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'core.course' | translate}}</h2> | ||||||
|  |                             <p> | ||||||
|  |                                 <core-format-text [text]="course.fullname" contextLevel="course" [contextInstanceId]="courseId"> | ||||||
|  |                                 </core-format-text> | ||||||
|  |                             </p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <!-- Criteria (not yet available) --> | ||||||
|  |                 </ion-item-group> | ||||||
|  | 
 | ||||||
|  |                 <ion-item-group> | ||||||
|  |                     <ion-item-divider> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.issuancedetails' | translate}}</h2> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item-divider> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.dateissued"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.dateawarded' | translate}}</h2> | ||||||
|  |                             <p>{{badge.dateissued * 1000 | coreFormatDate }}</p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.dateexpire"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.expirydate' | translate}}</h2> | ||||||
|  |                             <p> | ||||||
|  |                                 {{ badge.dateexpire * 1000 | coreFormatDate }} | ||||||
|  |                                 <span class="text-danger" *ngIf="currentTime >= badge.dateexpire"> | ||||||
|  |                                     {{ 'addon.badges.warnexpired' | translate }} | ||||||
|  |                                 </span> | ||||||
|  |                             </p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <!-- Evidence (not yet available) --> | ||||||
|  |                 </ion-item-group> | ||||||
|  | 
 | ||||||
|  |                 <!-- Endorsement --> | ||||||
|  |                 <ion-item-group *ngIf="badge.endorsement"> | ||||||
|  |                     <ion-item-divider> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.bendorsement' | translate}}</h2> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item-divider> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issuername"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.issuername' | translate}}</h2> | ||||||
|  |                             <p>{{ badge.endorsement.issuername }}</p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issueremail"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.issueremail' | translate}}</h2> | ||||||
|  |                             <p> | ||||||
|  |                                 <a href="mailto:{{badge.endorsement.issueremail}}" core-link auto-login="no" [showBrowserWarning]="false"> | ||||||
|  |                                     {{ badge.endorsement.issueremail }} | ||||||
|  |                                 </a> | ||||||
|  |                             </p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issuerurl"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.issuerurl' | translate}}</h2> | ||||||
|  |                             <p><a [href]="badge.endorsement.issuerurl" core-link auto-login="no"> {{ badge.endorsement.issuerurl }} </a></p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.dateissued"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.dateawarded' | translate}}</h2> | ||||||
|  |                             <p>{{ badge.endorsement.dateissued * 1000 | coreFormatDate }}</p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.claimid"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.claimid' | translate}}</h2> | ||||||
|  |                             <p><a [href]="badge.endorsement.claimid" core-link auto-login="no"> {{ badge.endorsement.claimid }} </a></p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.claimcomment"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.claimcomment' | translate}}</h2> | ||||||
|  |                             <p>{{ badge.endorsement.claimcomment }}</p> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                 </ion-item-group> | ||||||
|  | 
 | ||||||
|  |                 <!-- Related badges --> | ||||||
|  |                 <ion-item-group *ngIf="badge.relatedbadges"> | ||||||
|  |                     <ion-item-divider> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.relatedbages' | translate}}</h2> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item-divider> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngFor="let relatedBadge of badge.relatedbadges"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ relatedBadge.name }}</h2> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.relatedbadges.length == 0"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.norelated' | translate}}</h2> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                 </ion-item-group> | ||||||
|  | 
 | ||||||
|  |                 <!-- Competencies alignment --> | ||||||
|  |                 <ion-item-group *ngIf="badge.alignment"> | ||||||
|  |                     <ion-item-divider> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.alignment' | translate}}</h2> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item-divider> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngFor="let alignment of badge.alignment" [href]="alignment.targeturl" core-link | ||||||
|  |                         auto-login="no"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ alignment.targetname }}</h2> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                     <ion-item class="ion-text-wrap" *ngIf="badge.alignment.length == 0"> | ||||||
|  |                         <ion-label> | ||||||
|  |                             <h2>{{ 'addon.badges.noalignment' | translate}}</h2> | ||||||
|  |                         </ion-label> | ||||||
|  |                     </ion-item> | ||||||
|  |                 </ion-item-group> | ||||||
|  |             </ng-container> | ||||||
|  |         </core-loading> | ||||||
|  |     </core-swipe-navigation> | ||||||
| </ion-content> | </ion-content> | ||||||
|  | |||||||
| @ -23,6 +23,9 @@ import { CoreUtils } from '@services/utils/utils'; | |||||||
| import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses'; | import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses'; | ||||||
| import { CoreNavigator } from '@services/navigator'; | import { CoreNavigator } from '@services/navigator'; | ||||||
| import { ActivatedRoute } from '@angular/router'; | import { ActivatedRoute } from '@angular/router'; | ||||||
|  | import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||||
|  | import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||||
|  | import { AddonBadgesUserBadgesSource } from '@addons/badges/classes/user-badges-source'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Page that displays the list of calendar events. |  * Page that displays the list of calendar events. | ||||||
| @ -40,12 +43,11 @@ export class AddonBadgesIssuedBadgePage implements OnInit { | |||||||
|     user?: CoreUserProfile; |     user?: CoreUserProfile; | ||||||
|     course?: CoreEnrolledCourseData; |     course?: CoreEnrolledCourseData; | ||||||
|     badge?: AddonBadgesUserBadge; |     badge?: AddonBadgesUserBadge; | ||||||
|  |     badges?: CoreSwipeItemsManager; | ||||||
|     badgeLoaded = false; |     badgeLoaded = false; | ||||||
|     currentTime = 0; |     currentTime = 0; | ||||||
| 
 | 
 | ||||||
|     constructor( |     constructor(protected route: ActivatedRoute) { } | ||||||
|         protected route: ActivatedRoute, |  | ||||||
|     ) { } |  | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * View loaded. |      * View loaded. | ||||||
| @ -58,6 +60,11 @@ export class AddonBadgesIssuedBadgePage implements OnInit { | |||||||
|         this.fetchIssuedBadge().finally(() => { |         this.fetchIssuedBadge().finally(() => { | ||||||
|             this.badgeLoaded = true; |             this.badgeLoaded = true; | ||||||
|         }); |         }); | ||||||
|  | 
 | ||||||
|  |         const source = CoreItemsManagerSourcesTracker.getOrCreateSource(AddonBadgesUserBadgesSource, [this.courseId, this.userId]); | ||||||
|  |         this.badges = new CoreSwipeItemsManager(source); | ||||||
|  | 
 | ||||||
|  |         this.badges.start(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | |||||||
| @ -19,10 +19,11 @@ import { CoreTimeUtils } from '@services/utils/time'; | |||||||
| import { CoreDomUtils } from '@services/utils/dom'; | import { CoreDomUtils } from '@services/utils/dom'; | ||||||
| import { CoreSites } from '@services/sites'; | import { CoreSites } from '@services/sites'; | ||||||
| import { CoreUtils } from '@services/utils/utils'; | import { CoreUtils } from '@services/utils/utils'; | ||||||
| import { CorePageItemsListManager } from '@classes/page-items-list-manager'; |  | ||||||
| import { Params } from '@angular/router'; |  | ||||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||||
| import { CoreNavigator } from '@services/navigator'; | import { CoreNavigator } from '@services/navigator'; | ||||||
|  | import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; | ||||||
|  | import { AddonBadgesUserBadgesSource } from '@addons/badges/classes/user-badges-source'; | ||||||
|  | import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Page that displays the list of calendar events. |  * Page that displays the list of calendar events. | ||||||
| @ -34,7 +35,7 @@ import { CoreNavigator } from '@services/navigator'; | |||||||
| export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy { | export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy { | ||||||
| 
 | 
 | ||||||
|     currentTime = 0; |     currentTime = 0; | ||||||
|     badges: AddonBadgesUserBadgesManager; |     badges: CoreListItemsManager<AddonBadgesUserBadge, AddonBadgesUserBadgesSource>; | ||||||
| 
 | 
 | ||||||
|     @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; |     @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; | ||||||
| 
 | 
 | ||||||
| @ -47,7 +48,10 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy { | |||||||
|             courseId = 0; |             courseId = 0; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         this.badges = new AddonBadgesUserBadgesManager(AddonBadgesUserBadgesPage, courseId, userId); |         this.badges = new CoreListItemsManager( | ||||||
|  |             CoreItemsManagerSourcesTracker.getOrCreateSource(AddonBadgesUserBadgesSource, [courseId, userId]), | ||||||
|  |             AddonBadgesUserBadgesPage, | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -72,8 +76,13 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy { | |||||||
|      * @param refresher Refresher. |      * @param refresher Refresher. | ||||||
|      */ |      */ | ||||||
|     async refreshBadges(refresher?: IonRefresher): Promise<void> { |     async refreshBadges(refresher?: IonRefresher): Promise<void> { | ||||||
|         await CoreUtils.ignoreErrors(AddonBadges.invalidateUserBadges(this.badges.courseId, this.badges.userId)); |         await CoreUtils.ignoreErrors( | ||||||
|         await CoreUtils.ignoreErrors(this.fetchBadges()); |             AddonBadges.invalidateUserBadges( | ||||||
|  |                 this.badges.getSource().COURSE_ID, | ||||||
|  |                 this.badges.getSource().USER_ID, | ||||||
|  |             ), | ||||||
|  |         ); | ||||||
|  |         await CoreUtils.ignoreErrors(this.badges.reload()); | ||||||
| 
 | 
 | ||||||
|         refresher?.complete(); |         refresher?.complete(); | ||||||
|     } |     } | ||||||
| @ -85,55 +94,12 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy { | |||||||
|         this.currentTime = CoreTimeUtils.timestamp(); |         this.currentTime = CoreTimeUtils.timestamp(); | ||||||
| 
 | 
 | ||||||
|         try { |         try { | ||||||
|             await this.fetchBadges(); |             await this.badges.reload(); | ||||||
|         } catch (message) { |         } catch (message) { | ||||||
|             CoreDomUtils.showErrorModalDefault(message, 'Error loading badges'); |             CoreDomUtils.showErrorModalDefault(message, 'Error loading badges'); | ||||||
| 
 | 
 | ||||||
|             this.badges.setItems([]); |             this.badges.reset(); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |  | ||||||
|      * Update the list of badges. |  | ||||||
|      */ |  | ||||||
|     private async fetchBadges(): Promise<void> { |  | ||||||
|         const badges = await AddonBadges.getUserBadges(this.badges.courseId, this.badges.userId); |  | ||||||
| 
 |  | ||||||
|         this.badges.setItems(badges); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Helper class to manage badges. |  | ||||||
|  */ |  | ||||||
| class AddonBadgesUserBadgesManager extends CorePageItemsListManager<AddonBadgesUserBadge> { |  | ||||||
| 
 |  | ||||||
|     courseId: number; |  | ||||||
|     userId: number; |  | ||||||
| 
 |  | ||||||
|     constructor(pageComponent: unknown, courseId: number, userId: number) { |  | ||||||
|         super(pageComponent); |  | ||||||
| 
 |  | ||||||
|         this.courseId = courseId; |  | ||||||
|         this.userId = userId; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * @inheritdoc |  | ||||||
|      */ |  | ||||||
|     protected getItemPath(badge: AddonBadgesUserBadge): string { |  | ||||||
|         return badge.uniquehash; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * @inheritdoc |  | ||||||
|      */ |  | ||||||
|     protected getItemQueryParams(): Params { |  | ||||||
|         return { |  | ||||||
|             courseId: this.courseId, |  | ||||||
|             userId: this.userId, |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										257
									
								
								src/addons/mod/assign/classes/submissions-source.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										257
									
								
								src/addons/mod/assign/classes/submissions-source.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,257 @@ | |||||||
|  | // (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 { Params } from '@angular/router'; | ||||||
|  | import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||||
|  | import { CoreGroupInfo, CoreGroups } from '@services/groups'; | ||||||
|  | import { CoreSites } from '@services/sites'; | ||||||
|  | import { CoreUtils } from '@services/utils/utils'; | ||||||
|  | import { Translate } from '@singletons'; | ||||||
|  | import { CoreEvents } from '@singletons/events'; | ||||||
|  | import { | ||||||
|  |     AddonModAssign, | ||||||
|  |     AddonModAssignAssign, | ||||||
|  |     AddonModAssignGrade, | ||||||
|  |     AddonModAssignProvider, | ||||||
|  |     AddonModAssignSubmission, | ||||||
|  | } from '../services/assign'; | ||||||
|  | import { AddonModAssignHelper, AddonModAssignSubmissionFormatted } from '../services/assign-helper'; | ||||||
|  | import { AddonModAssignOffline } from '../services/assign-offline'; | ||||||
|  | import { AddonModAssignSync, AddonModAssignSyncProvider } from '../services/assign-sync'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Provides a collection of assignment submissions. | ||||||
|  |  */ | ||||||
|  | export class AddonModAssignSubmissionsSource extends CoreItemsManagerSource<AddonModAssignSubmissionForList> { | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     static getSourceId(courseId: number, moduleId: number, selectedStatus?: string): string { | ||||||
|  |         selectedStatus = selectedStatus ?? '__empty__'; | ||||||
|  | 
 | ||||||
|  |         return `submissions-${courseId}-${moduleId}-${selectedStatus}`; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     readonly COURSE_ID: number; | ||||||
|  |     readonly MODULE_ID: number; | ||||||
|  |     readonly SELECTED_STATUS: string | undefined; | ||||||
|  | 
 | ||||||
|  |     assign?: AddonModAssignAssign; | ||||||
|  |     groupId = 0; | ||||||
|  |     groupInfo: CoreGroupInfo = { | ||||||
|  |         groups: [], | ||||||
|  |         separateGroups: false, | ||||||
|  |         visibleGroups: false, | ||||||
|  |         defaultGroupId: 0, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     protected submissionsData: { canviewsubmissions: boolean; submissions?: AddonModAssignSubmission[] } = { | ||||||
|  |         canviewsubmissions: false, | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     constructor(courseId: number, moduleId: number, selectedStatus?: string) { | ||||||
|  |         super(); | ||||||
|  | 
 | ||||||
|  |         this.COURSE_ID = courseId; | ||||||
|  |         this.MODULE_ID = moduleId; | ||||||
|  |         this.SELECTED_STATUS = selectedStatus; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     getItemPath(submission: AddonModAssignSubmissionForList): string { | ||||||
|  |         return String(submission.submitid); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     getItemQueryParams(submission: AddonModAssignSubmissionForList): Params { | ||||||
|  |         return { | ||||||
|  |             blindId: submission.blindid, | ||||||
|  |             groupId: this.groupId, | ||||||
|  |             selectedStatus: this.SELECTED_STATUS, | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Invalidate assignment cache. | ||||||
|  |      */ | ||||||
|  |     async invalidateCache(): Promise<void> { | ||||||
|  |         await Promise.all([ | ||||||
|  |             AddonModAssign.invalidateAssignmentData(this.COURSE_ID), | ||||||
|  |             this.assign && AddonModAssign.invalidateAllSubmissionData(this.assign.id), | ||||||
|  |             this.assign && AddonModAssign.invalidateAssignmentUserMappingsData(this.assign.id), | ||||||
|  |             this.assign && AddonModAssign.invalidateAssignmentGradesData(this.assign.id), | ||||||
|  |             this.assign && AddonModAssign.invalidateListParticipantsData(this.assign.id), | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Load assignment. | ||||||
|  |      */ | ||||||
|  |     async loadAssignment(sync: boolean = false): Promise<void> { | ||||||
|  |         // Get assignment data.
 | ||||||
|  |         this.assign = await AddonModAssign.getAssignment(this.COURSE_ID, this.MODULE_ID); | ||||||
|  | 
 | ||||||
|  |         if (sync) { | ||||||
|  |             try { | ||||||
|  |                 // Try to synchronize data.
 | ||||||
|  |                 const result = await AddonModAssignSync.syncAssign(this.assign.id); | ||||||
|  | 
 | ||||||
|  |                 if (result && result.updated) { | ||||||
|  |                     CoreEvents.trigger( | ||||||
|  |                         AddonModAssignSyncProvider.MANUAL_SYNCED, | ||||||
|  |                         { | ||||||
|  |                             assignId: this.assign.id, | ||||||
|  |                             warnings: result.warnings, | ||||||
|  |                             gradesBlocked: result.gradesBlocked, | ||||||
|  |                             context: 'submission-list', | ||||||
|  |                         }, | ||||||
|  |                         CoreSites.getCurrentSiteId(), | ||||||
|  |                     ); | ||||||
|  |                 } | ||||||
|  |             } catch (error) { | ||||||
|  |                 // Ignore errors, probably user is offline or sync is blocked.
 | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Get assignment submissions.
 | ||||||
|  |         this.submissionsData = await AddonModAssign.getSubmissions(this.assign.id, { cmId: this.assign.cmid }); | ||||||
|  | 
 | ||||||
|  |         if (!this.submissionsData.canviewsubmissions) { | ||||||
|  |             // User shouldn't be able to reach here.
 | ||||||
|  |             throw new Error('Cannot view submissions.'); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Check if groupmode is enabled to avoid showing wrong numbers.
 | ||||||
|  |         this.groupInfo = await CoreGroups.getActivityGroupInfo(this.assign.cmid, false); | ||||||
|  | 
 | ||||||
|  |         this.groupId = CoreGroups.validateGroupId(this.groupId, this.groupInfo); | ||||||
|  | 
 | ||||||
|  |         await this.reload(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     protected async loadPageItems(): Promise<{ items: AddonModAssignSubmissionForList[] }> { | ||||||
|  |         const assign = this.assign; | ||||||
|  | 
 | ||||||
|  |         if (!assign) { | ||||||
|  |             throw new Error('Can\'t load submissions without assignment'); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Fetch submissions and grades.
 | ||||||
|  |         const submissions = | ||||||
|  |             await AddonModAssignHelper.getSubmissionsUserData( | ||||||
|  |                 assign, | ||||||
|  |                 this.submissionsData.submissions, | ||||||
|  |                 this.groupId, | ||||||
|  |             ); | ||||||
|  |         // Get assignment grades only if workflow is not enabled to check grading date.
 | ||||||
|  |         const grades = !assign.markingworkflow | ||||||
|  |             ? await AddonModAssign.getAssignmentGrades(assign.id, { cmId: assign.cmid }) | ||||||
|  |             : []; | ||||||
|  | 
 | ||||||
|  |         // Filter the submissions to get only the ones with the right status and add some extra data.
 | ||||||
|  |         const getNeedGrading = this.SELECTED_STATUS == AddonModAssignProvider.NEED_GRADING; | ||||||
|  |         const searchStatus = getNeedGrading ? AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED : this.SELECTED_STATUS; | ||||||
|  | 
 | ||||||
|  |         const promises: Promise<void>[] = []; | ||||||
|  |         const showSubmissions: AddonModAssignSubmissionForList[] = []; | ||||||
|  | 
 | ||||||
|  |         submissions.forEach((submission: AddonModAssignSubmissionForList) => { | ||||||
|  |             if (!searchStatus || searchStatus == submission.status) { | ||||||
|  |                 promises.push( | ||||||
|  |                     CoreUtils.ignoreErrors( | ||||||
|  |                         AddonModAssignOffline.getSubmissionGrade(assign.id, submission.userid), | ||||||
|  |                     ).then(async (data) => { | ||||||
|  |                         if (getNeedGrading) { | ||||||
|  |                             // Only show the submissions that need to be graded.
 | ||||||
|  |                             const add = await AddonModAssign.needsSubmissionToBeGraded(submission, assign.id); | ||||||
|  | 
 | ||||||
|  |                             if (!add) { | ||||||
|  |                                 return; | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  | 
 | ||||||
|  |                         // Load offline grades.
 | ||||||
|  |                         const notSynced = !!data && submission.timemodified < data.timemodified; | ||||||
|  | 
 | ||||||
|  |                         if (submission.gradingstatus == 'graded' && !assign.markingworkflow) { | ||||||
|  |                             // Get the last grade of the submission.
 | ||||||
|  |                             const grade = grades | ||||||
|  |                                 .filter((grade) => grade.userid == submission.userid) | ||||||
|  |                                 .reduce( | ||||||
|  |                                     (a, b) => (a && a.timemodified > b.timemodified ? a : b), | ||||||
|  |                                     <AddonModAssignGrade | undefined> undefined, | ||||||
|  |                                 ); | ||||||
|  | 
 | ||||||
|  |                             if (grade && grade.timemodified < submission.timemodified) { | ||||||
|  |                                 submission.gradingstatus = AddonModAssignProvider.GRADED_FOLLOWUP_SUBMIT; | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                         submission.statusColor = AddonModAssign.getSubmissionStatusColor(submission.status); | ||||||
|  |                         submission.gradingColor = AddonModAssign.getSubmissionGradingStatusColor( | ||||||
|  |                             submission.gradingstatus, | ||||||
|  |                         ); | ||||||
|  | 
 | ||||||
|  |                         // Show submission status if not submitted for grading.
 | ||||||
|  |                         if (submission.statusColor != 'success' || !submission.gradingstatus) { | ||||||
|  |                             submission.statusTranslated = Translate.instant( | ||||||
|  |                                 'addon.mod_assign.submissionstatus_' + submission.status, | ||||||
|  |                             ); | ||||||
|  |                         } else { | ||||||
|  |                             submission.statusTranslated = ''; | ||||||
|  |                         } | ||||||
|  | 
 | ||||||
|  |                         if (notSynced) { | ||||||
|  |                             submission.gradingStatusTranslationId = 'addon.mod_assign.gradenotsynced'; | ||||||
|  |                             submission.gradingColor = ''; | ||||||
|  |                         } else if (submission.statusColor != 'danger' || submission.gradingColor != 'danger') { | ||||||
|  |                             // Show grading status if one of the statuses is not done.
 | ||||||
|  |                             submission.gradingStatusTranslationId = AddonModAssign.getSubmissionGradingStatusTranslationId( | ||||||
|  |                                 submission.gradingstatus, | ||||||
|  |                             ); | ||||||
|  |                         } else { | ||||||
|  |                             submission.gradingStatusTranslationId = ''; | ||||||
|  |                         } | ||||||
|  | 
 | ||||||
|  |                         showSubmissions.push(submission); | ||||||
|  | 
 | ||||||
|  |                         return; | ||||||
|  |                     }), | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         await Promise.all(promises); | ||||||
|  | 
 | ||||||
|  |         return { items: showSubmissions }; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Calculated data for an assign submission. | ||||||
|  |  */ | ||||||
|  | export type AddonModAssignSubmissionForList = AddonModAssignSubmissionFormatted & { | ||||||
|  |     statusColor?: string; // Calculated in the app. Color of the submission status.
 | ||||||
|  |     gradingColor?: string; // Calculated in the app. Color of the submission grading status.
 | ||||||
|  |     statusTranslated?: string; // Calculated in the app. Translated text of the submission status.
 | ||||||
|  |     gradingStatusTranslationId?: string; // Calculated in the app. Key of the text of the submission grading status.
 | ||||||
|  | }; | ||||||
| @ -16,10 +16,10 @@ | |||||||
| 
 | 
 | ||||||
| <ion-content> | <ion-content> | ||||||
|     <core-split-view> |     <core-split-view> | ||||||
|         <ion-refresher slot="fixed" [disabled]="!loaded || !submissions.loaded" (ionRefresh)="refreshList($event.target)"> |         <ion-refresher slot="fixed" [disabled]="!submissions.loaded" (ionRefresh)="refreshList($event.target)"> | ||||||
|             <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> |             <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||||
|         </ion-refresher> |         </ion-refresher> | ||||||
|         <core-loading [hideUntil]="loaded && submissions.loaded"> |         <core-loading [hideUntil]="submissions.loaded"> | ||||||
|             <core-empty-box *ngIf="!submissions || submissions.empty" icon="fas-file-signature" |             <core-empty-box *ngIf="!submissions || submissions.empty" icon="fas-file-signature" | ||||||
|                 [message]="'addon.mod_assign.submissionstatus_' | translate"> |                 [message]="'addon.mod_assign.submissionstatus_' | translate"> | ||||||
|             </core-empty-box> |             </core-empty-box> | ||||||
| @ -32,7 +32,7 @@ | |||||||
|                     <ion-label id="addon-assign-groupslabel" *ngIf="groupInfo.visibleGroups"> |                     <ion-label id="addon-assign-groupslabel" *ngIf="groupInfo.visibleGroups"> | ||||||
|                         {{ 'core.groupsvisible' | translate }} |                         {{ 'core.groupsvisible' | translate }} | ||||||
|                     </ion-label> |                     </ion-label> | ||||||
|                     <ion-select [(ngModel)]="groupId" (ionChange)="setGroup(groupId)" aria-labelledby="addon-assign-groupslabel" |                     <ion-select [(ngModel)]="groupId" (ionChange)="reloadSubmissions()" aria-labelledby="addon-assign-groupslabel" | ||||||
|                         interface="action-sheet" slot="end" [interfaceOptions]="{header: 'core.group' | translate}"> |                         interface="action-sheet" slot="end" [interfaceOptions]="{header: 'core.group' | translate}"> | ||||||
|                         <ion-select-option *ngFor="let groupOpt of groupInfo.groups" [value]="groupOpt.id"> |                         <ion-select-option *ngFor="let groupOpt of groupInfo.groups" [value]="groupOpt.id"> | ||||||
|                             {{groupOpt.name}} |                             {{groupOpt.name}} | ||||||
|  | |||||||
| @ -13,29 +13,20 @@ | |||||||
| // limitations under the License.
 | // limitations under the License.
 | ||||||
| 
 | 
 | ||||||
| import { Component, OnDestroy, AfterViewInit, ViewChild } from '@angular/core'; | import { Component, OnDestroy, AfterViewInit, ViewChild } from '@angular/core'; | ||||||
| import { Params } from '@angular/router'; | import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||||
| import { CorePageItemsListManager } from '@classes/page-items-list-manager'; | import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; | ||||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||||
| import { IonRefresher } from '@ionic/angular'; | import { IonRefresher } from '@ionic/angular'; | ||||||
| import { CoreGroupInfo, CoreGroups } from '@services/groups'; | import { CoreGroupInfo } from '@services/groups'; | ||||||
| import { CoreNavigator } from '@services/navigator'; | import { CoreNavigator } from '@services/navigator'; | ||||||
| import { CoreSites } from '@services/sites'; | import { CoreSites } from '@services/sites'; | ||||||
| import { CoreDomUtils } from '@services/utils/dom'; | import { CoreDomUtils } from '@services/utils/dom'; | ||||||
| import { CoreUtils } from '@services/utils/utils'; |  | ||||||
| import { Translate } from '@singletons'; | import { Translate } from '@singletons'; | ||||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||||
| import { | import { AddonModAssignSubmissionForList, AddonModAssignSubmissionsSource } from '../../classes/submissions-source'; | ||||||
|     AddonModAssignAssign, | import { AddonModAssignAssign, AddonModAssignProvider } from '../../services/assign'; | ||||||
|     AddonModAssignSubmission, |  | ||||||
|     AddonModAssignProvider, |  | ||||||
|     AddonModAssign, |  | ||||||
|     AddonModAssignGrade, |  | ||||||
| } from '../../services/assign'; |  | ||||||
| import { AddonModAssignHelper, AddonModAssignSubmissionFormatted } from '../../services/assign-helper'; |  | ||||||
| import { AddonModAssignOffline } from '../../services/assign-offline'; |  | ||||||
| import { | import { | ||||||
|     AddonModAssignSyncProvider, |     AddonModAssignSyncProvider, | ||||||
|     AddonModAssignSync, |  | ||||||
|     AddonModAssignManualSyncData, |     AddonModAssignManualSyncData, | ||||||
|     AddonModAssignAutoSyncData, |     AddonModAssignAutoSyncData, | ||||||
| } from '../../services/assign-sync'; | } from '../../services/assign-sync'; | ||||||
| @ -51,47 +42,26 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | |||||||
| 
 | 
 | ||||||
|     @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; |     @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; | ||||||
| 
 | 
 | ||||||
|     title = ''; // Title to display.
 |     title = ''; | ||||||
|     assign?: AddonModAssignAssign; // Assignment.
 |     submissions!: CoreListItemsManager<AddonModAssignSubmissionForList, AddonModAssignSubmissionsSource>; // List of submissions
 | ||||||
|     submissions: AddonModAssignSubmissionListManager; // List of submissions
 |  | ||||||
|     loaded = false; // Whether data has been loaded.
 |  | ||||||
|     groupId = 0; // Group ID to show.
 |  | ||||||
|     courseId!: number; // Course ID the assignment belongs to.
 |  | ||||||
|     moduleId!: number; // Module ID the submission belongs to.
 |  | ||||||
| 
 | 
 | ||||||
|     groupInfo: CoreGroupInfo = { |  | ||||||
|         groups: [], |  | ||||||
|         separateGroups: false, |  | ||||||
|         visibleGroups: false, |  | ||||||
|         defaultGroupId: 0, |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     protected selectedStatus?: string; // The status to see.
 |  | ||||||
|     protected gradedObserver: CoreEventObserver; // Observer to refresh data when a grade changes.
 |     protected gradedObserver: CoreEventObserver; // Observer to refresh data when a grade changes.
 | ||||||
|     protected syncObserver: CoreEventObserver; // Observer to refresh data when the async is synchronized.
 |     protected syncObserver: CoreEventObserver; // Observer to refresh data when the async is synchronized.
 | ||||||
|     protected submissionsData: { canviewsubmissions: boolean; submissions?: AddonModAssignSubmission[] } = { |     protected sourceUnsubscribe?: () => void; | ||||||
|         canviewsubmissions: false, |  | ||||||
|     }; |  | ||||||
| 
 | 
 | ||||||
|     constructor() { |     constructor() { | ||||||
|         this.submissions = new AddonModAssignSubmissionListManager(AddonModAssignSubmissionListPage); |  | ||||||
| 
 |  | ||||||
|         // Update data if some grade changes.
 |         // Update data if some grade changes.
 | ||||||
|         this.gradedObserver = CoreEvents.on( |         this.gradedObserver = CoreEvents.on( | ||||||
|             AddonModAssignProvider.GRADED_EVENT, |             AddonModAssignProvider.GRADED_EVENT, | ||||||
|             (data) => { |             (data) => { | ||||||
|                 if ( |                 if ( | ||||||
|                     this.loaded && |                     this.submissions.loaded && | ||||||
|                     this.assign && |                     this.submissions.getSource().assign && | ||||||
|                     data.assignmentId == this.assign.id && |                     data.assignmentId == this.submissions.getSource().assign?.id && | ||||||
|                     data.userId == CoreSites.getCurrentSiteUserId() |                     data.userId == CoreSites.getCurrentSiteUserId() | ||||||
|                 ) { |                 ) { | ||||||
|                     // Grade changed, refresh the data.
 |                     // Grade changed, refresh the data.
 | ||||||
|                     this.loaded = false; |                     this.refreshAllData(true); | ||||||
| 
 |  | ||||||
|                     this.refreshAllData(true).finally(() => { |  | ||||||
|                         this.loaded = true; |  | ||||||
|                     }); |  | ||||||
|                 } |                 } | ||||||
|             }, |             }, | ||||||
|             CoreSites.getCurrentSiteId(), |             CoreSites.getCurrentSiteId(), | ||||||
| @ -102,29 +72,36 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | |||||||
|         this.syncObserver = CoreEvents.onMultiple<AddonModAssignAutoSyncData | AddonModAssignManualSyncData>( |         this.syncObserver = CoreEvents.onMultiple<AddonModAssignAutoSyncData | AddonModAssignManualSyncData>( | ||||||
|             events, |             events, | ||||||
|             (data) => { |             (data) => { | ||||||
|                 if (!this.loaded || ('context' in data && data.context == 'submission-list')) { |                 if (!this.submissions.loaded || ('context' in data && data.context == 'submission-list')) { | ||||||
|                     return; |                     return; | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|                 this.loaded = false; |                 this.refreshAllData(false); | ||||||
| 
 |  | ||||||
|                 this.refreshAllData(false).finally(() => { |  | ||||||
|                     this.loaded = true; |  | ||||||
|                 }); |  | ||||||
|             }, |             }, | ||||||
|             CoreSites.getCurrentSiteId(), |             CoreSites.getCurrentSiteId(), | ||||||
|         ); |         ); | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     /** |  | ||||||
|      * Component being initialized. |  | ||||||
|      */ |  | ||||||
|     ngAfterViewInit(): void { |  | ||||||
|         try { |         try { | ||||||
|             this.moduleId = CoreNavigator.getRequiredRouteNumberParam('cmId'); |             const moduleId = CoreNavigator.getRequiredRouteNumberParam('cmId'); | ||||||
|             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); |             const courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||||
|             this.groupId = CoreNavigator.getRouteNumberParam('groupId') || 0; |             const groupId = CoreNavigator.getRouteNumberParam('groupId') || 0; | ||||||
|             this.selectedStatus = CoreNavigator.getRouteParam('status'); |             const selectedStatus = CoreNavigator.getRouteParam('status'); | ||||||
|  |             const submissionsSource = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||||
|  |                 AddonModAssignSubmissionsSource, | ||||||
|  |                 [courseId, moduleId, selectedStatus], | ||||||
|  |             ); | ||||||
|  | 
 | ||||||
|  |             submissionsSource.groupId = groupId; | ||||||
|  |             this.sourceUnsubscribe = submissionsSource.addListener({ | ||||||
|  |                 onItemsUpdated: () => { | ||||||
|  |                     this.title = this.submissions.getSource().assign?.name || this.title; | ||||||
|  |                 }, | ||||||
|  |             }); | ||||||
|  | 
 | ||||||
|  |             this.submissions = new CoreListItemsManager( | ||||||
|  |                 submissionsSource, | ||||||
|  |                 AddonModAssignSubmissionListPage, | ||||||
|  |             ); | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             CoreDomUtils.showErrorModal(error); |             CoreDomUtils.showErrorModal(error); | ||||||
| 
 | 
 | ||||||
| @ -132,18 +109,48 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | |||||||
| 
 | 
 | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     get assign(): AddonModAssignAssign | undefined { | ||||||
|  |         return this.submissions.getSource().assign; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     get groupInfo(): CoreGroupInfo { | ||||||
|  |         return this.submissions.getSource().groupInfo; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     get moduleId(): number { | ||||||
|  |         return this.submissions.getSource().MODULE_ID; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     get courseId(): number { | ||||||
|  |         return this.submissions.getSource().COURSE_ID; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     get groupId(): number { | ||||||
|  |         return this.submissions.getSource().groupId; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     set groupId(value: number) { | ||||||
|  |         this.submissions.getSource().groupId = value; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     ngAfterViewInit(): void { | ||||||
|  |         const selectedStatus = this.submissions.getSource().SELECTED_STATUS; | ||||||
|  |         this.title = Translate.instant( | ||||||
|  |             selectedStatus | ||||||
|  |                 ? ( | ||||||
|  |                     selectedStatus === AddonModAssignProvider.NEED_GRADING | ||||||
|  |                         ? 'addon.mod_assign.numberofsubmissionsneedgrading' | ||||||
|  |                         : `addon.mod_assign.submissionstatus_${selectedStatus}` | ||||||
|  |                 ) | ||||||
|  |                 : 'addon.mod_assign.numberofparticipants', | ||||||
|  |         ); | ||||||
| 
 | 
 | ||||||
|         if (this.selectedStatus) { |  | ||||||
|             if (this.selectedStatus == AddonModAssignProvider.NEED_GRADING) { |  | ||||||
|                 this.title = Translate.instant('addon.mod_assign.numberofsubmissionsneedgrading'); |  | ||||||
|             } else { |  | ||||||
|                 this.title = Translate.instant('addon.mod_assign.submissionstatus_' + this.selectedStatus); |  | ||||||
|             } |  | ||||||
|         } else { |  | ||||||
|             this.title = Translate.instant('addon.mod_assign.numberofparticipants'); |  | ||||||
|         } |  | ||||||
|         this.fetchAssignment(true).finally(() => { |         this.fetchAssignment(true).finally(() => { | ||||||
|             this.loaded = true; |  | ||||||
|             this.submissions.start(this.splitView); |             this.submissions.start(this.splitView); | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| @ -156,148 +163,12 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | |||||||
|      */ |      */ | ||||||
|     protected async fetchAssignment(sync = false): Promise<void> { |     protected async fetchAssignment(sync = false): Promise<void> { | ||||||
|         try { |         try { | ||||||
|             // Get assignment data.
 |             await this.submissions.getSource().loadAssignment(sync); | ||||||
|             this.assign = await AddonModAssign.getAssignment(this.courseId, this.moduleId); |  | ||||||
| 
 |  | ||||||
|             this.title = this.assign.name || this.title; |  | ||||||
| 
 |  | ||||||
|             if (sync) { |  | ||||||
|                 try { |  | ||||||
|                     // Try to synchronize data.
 |  | ||||||
|                     const result = await AddonModAssignSync.syncAssign(this.assign.id); |  | ||||||
| 
 |  | ||||||
|                     if (result && result.updated) { |  | ||||||
|                         CoreEvents.trigger( |  | ||||||
|                             AddonModAssignSyncProvider.MANUAL_SYNCED, |  | ||||||
|                             { |  | ||||||
|                                 assignId: this.assign.id, |  | ||||||
|                                 warnings: result.warnings, |  | ||||||
|                                 gradesBlocked: result.gradesBlocked, |  | ||||||
|                                 context: 'submission-list', |  | ||||||
|                             }, |  | ||||||
|                             CoreSites.getCurrentSiteId(), |  | ||||||
|                         ); |  | ||||||
|                     } |  | ||||||
|                 } catch (error) { |  | ||||||
|                     // Ignore errors, probably user is offline or sync is blocked.
 |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             // Get assignment submissions.
 |  | ||||||
|             this.submissionsData = await AddonModAssign.getSubmissions(this.assign.id, { cmId: this.assign.cmid }); |  | ||||||
| 
 |  | ||||||
|             if (!this.submissionsData.canviewsubmissions) { |  | ||||||
|                 // User shouldn't be able to reach here.
 |  | ||||||
|                 throw new Error('Cannot view submissions.'); |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             // Check if groupmode is enabled to avoid showing wrong numbers.
 |  | ||||||
|             this.groupInfo = await CoreGroups.getActivityGroupInfo(this.assign.cmid, false); |  | ||||||
| 
 |  | ||||||
|             await this.setGroup(CoreGroups.validateGroupId(this.groupId, this.groupInfo)); |  | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             CoreDomUtils.showErrorModalDefault(error, 'Error getting assigment data.'); |             CoreDomUtils.showErrorModalDefault(error, 'Error getting assigment data.'); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |  | ||||||
|      * Set group to see the summary. |  | ||||||
|      * |  | ||||||
|      * @param groupId Group ID. |  | ||||||
|      * @return Resolved when done. |  | ||||||
|      */ |  | ||||||
|     async setGroup(groupId: number): Promise<void> { |  | ||||||
|         this.groupId = groupId; |  | ||||||
| 
 |  | ||||||
|         // Fetch submissions and grades.
 |  | ||||||
|         const submissions = |  | ||||||
|             await AddonModAssignHelper.getSubmissionsUserData( |  | ||||||
|                 this.assign!, |  | ||||||
|                 this.submissionsData.submissions, |  | ||||||
|                 this.groupId, |  | ||||||
|             ); |  | ||||||
|         // Get assignment grades only if workflow is not enabled to check grading date.
 |  | ||||||
|         const grades = !this.assign!.markingworkflow |  | ||||||
|             ? await AddonModAssign.getAssignmentGrades(this.assign!.id, { cmId: this.assign!.cmid }) |  | ||||||
|             : []; |  | ||||||
| 
 |  | ||||||
|         // Filter the submissions to get only the ones with the right status and add some extra data.
 |  | ||||||
|         const getNeedGrading = this.selectedStatus == AddonModAssignProvider.NEED_GRADING; |  | ||||||
|         const searchStatus = getNeedGrading ? AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED : this.selectedStatus; |  | ||||||
| 
 |  | ||||||
|         const promises: Promise<void>[] = []; |  | ||||||
|         const showSubmissions: AddonModAssignSubmissionForList[] = []; |  | ||||||
| 
 |  | ||||||
|         submissions.forEach((submission: AddonModAssignSubmissionForList) => { |  | ||||||
|             if (!searchStatus || searchStatus == submission.status) { |  | ||||||
|                 promises.push( |  | ||||||
|                     CoreUtils.ignoreErrors( |  | ||||||
|                         AddonModAssignOffline.getSubmissionGrade(this.assign!.id, submission.userid), |  | ||||||
|                     ).then(async (data) => { |  | ||||||
|                         if (getNeedGrading) { |  | ||||||
|                             // Only show the submissions that need to be graded.
 |  | ||||||
|                             const add = await AddonModAssign.needsSubmissionToBeGraded(submission, this.assign!.id); |  | ||||||
| 
 |  | ||||||
|                             if (!add) { |  | ||||||
|                                 return; |  | ||||||
|                             } |  | ||||||
|                         } |  | ||||||
| 
 |  | ||||||
|                         // Load offline grades.
 |  | ||||||
|                         const notSynced = !!data && submission.timemodified < data.timemodified; |  | ||||||
| 
 |  | ||||||
|                         if (submission.gradingstatus == 'graded' && !this.assign!.markingworkflow) { |  | ||||||
|                             // Get the last grade of the submission.
 |  | ||||||
|                             const grade = grades |  | ||||||
|                                 .filter((grade) => grade.userid == submission.userid) |  | ||||||
|                                 .reduce( |  | ||||||
|                                     (a, b) => (a && a.timemodified > b.timemodified ? a : b), |  | ||||||
|                                     <AddonModAssignGrade | undefined> undefined, |  | ||||||
|                                 ); |  | ||||||
| 
 |  | ||||||
|                             if (grade && grade.timemodified < submission.timemodified) { |  | ||||||
|                                 submission.gradingstatus = AddonModAssignProvider.GRADED_FOLLOWUP_SUBMIT; |  | ||||||
|                             } |  | ||||||
|                         } |  | ||||||
|                         submission.statusColor = AddonModAssign.getSubmissionStatusColor(submission.status); |  | ||||||
|                         submission.gradingColor = AddonModAssign.getSubmissionGradingStatusColor( |  | ||||||
|                             submission.gradingstatus, |  | ||||||
|                         ); |  | ||||||
| 
 |  | ||||||
|                         // Show submission status if not submitted for grading.
 |  | ||||||
|                         if (submission.statusColor != 'success' || !submission.gradingstatus) { |  | ||||||
|                             submission.statusTranslated = Translate.instant( |  | ||||||
|                                 'addon.mod_assign.submissionstatus_' + submission.status, |  | ||||||
|                             ); |  | ||||||
|                         } else { |  | ||||||
|                             submission.statusTranslated = ''; |  | ||||||
|                         } |  | ||||||
| 
 |  | ||||||
|                         if (notSynced) { |  | ||||||
|                             submission.gradingStatusTranslationId = 'addon.mod_assign.gradenotsynced'; |  | ||||||
|                             submission.gradingColor = ''; |  | ||||||
|                         } else if (submission.statusColor != 'danger' || submission.gradingColor != 'danger') { |  | ||||||
|                             // Show grading status if one of the statuses is not done.
 |  | ||||||
|                             submission.gradingStatusTranslationId = AddonModAssign.getSubmissionGradingStatusTranslationId( |  | ||||||
|                                 submission.gradingstatus, |  | ||||||
|                             ); |  | ||||||
|                         } else { |  | ||||||
|                             submission.gradingStatusTranslationId = ''; |  | ||||||
|                         } |  | ||||||
| 
 |  | ||||||
|                         showSubmissions.push(submission); |  | ||||||
| 
 |  | ||||||
|                         return; |  | ||||||
|                     }), |  | ||||||
|                 ); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         await Promise.all(promises); |  | ||||||
| 
 |  | ||||||
|         this.submissions.setItems(showSubmissions); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |     /** | ||||||
|      * Refresh all the data. |      * Refresh all the data. | ||||||
|      * |      * | ||||||
| @ -305,18 +176,8 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | |||||||
|      * @return Promise resolved when done. |      * @return Promise resolved when done. | ||||||
|      */ |      */ | ||||||
|     protected async refreshAllData(sync?: boolean): Promise<void> { |     protected async refreshAllData(sync?: boolean): Promise<void> { | ||||||
|         const promises: Promise<void>[] = []; |  | ||||||
| 
 |  | ||||||
|         promises.push(AddonModAssign.invalidateAssignmentData(this.courseId)); |  | ||||||
|         if (this.assign) { |  | ||||||
|             promises.push(AddonModAssign.invalidateAllSubmissionData(this.assign.id)); |  | ||||||
|             promises.push(AddonModAssign.invalidateAssignmentUserMappingsData(this.assign.id)); |  | ||||||
|             promises.push(AddonModAssign.invalidateAssignmentGradesData(this.assign.id)); |  | ||||||
|             promises.push(AddonModAssign.invalidateListParticipantsData(this.assign.id)); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         try { |         try { | ||||||
|             await Promise.all(promises); |             await this.submissions.getSource().invalidateCache(); | ||||||
|         } finally { |         } finally { | ||||||
|             this.fetchAssignment(sync); |             this.fetchAssignment(sync); | ||||||
|         } |         } | ||||||
| @ -333,6 +194,13 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | |||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * Reload submissions list. | ||||||
|  |      */ | ||||||
|  |     async reloadSubmissions(): Promise<void> { | ||||||
|  |         await this.submissions.reload(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * Component being destroyed. |      * Component being destroyed. | ||||||
|      */ |      */ | ||||||
| @ -340,43 +208,7 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | |||||||
|         this.gradedObserver?.off(); |         this.gradedObserver?.off(); | ||||||
|         this.syncObserver?.off(); |         this.syncObserver?.off(); | ||||||
|         this.submissions.destroy(); |         this.submissions.destroy(); | ||||||
|  |         this.sourceUnsubscribe && this.sourceUnsubscribe(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| } | } | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Helper class to manage submissions. |  | ||||||
|  */ |  | ||||||
| class AddonModAssignSubmissionListManager extends CorePageItemsListManager<AddonModAssignSubmissionForList> { |  | ||||||
| 
 |  | ||||||
|     constructor(pageComponent: unknown) { |  | ||||||
|         super(pageComponent); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * @inheritdoc |  | ||||||
|      */ |  | ||||||
|     protected getItemPath(submission: AddonModAssignSubmissionForList): string { |  | ||||||
|         return String(submission.submitid); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * @inheritdoc |  | ||||||
|      */ |  | ||||||
|     protected getItemQueryParams(submission: AddonModAssignSubmissionForList): Params { |  | ||||||
|         return { |  | ||||||
|             blindId: submission.blindid, |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Calculated data for an assign submission. |  | ||||||
|  */ |  | ||||||
| type AddonModAssignSubmissionForList = AddonModAssignSubmissionFormatted & { |  | ||||||
|     statusColor?: string; // Calculated in the app. Color of the submission status.
 |  | ||||||
|     gradingColor?: string; // Calculated in the app. Color of the submission grading status.
 |  | ||||||
|     statusTranslated?: string; // Calculated in the app. Translated text of the submission status.
 |  | ||||||
|     gradingStatusTranslationId?: string; // Calculated in the app. Key of the text of the submission grading status.
 |  | ||||||
| }; |  | ||||||
|  | |||||||
| @ -20,12 +20,14 @@ | |||||||
|     </core-navbar-buttons> |     </core-navbar-buttons> | ||||||
| </ion-header> | </ion-header> | ||||||
| <ion-content> | <ion-content> | ||||||
| 
 |     <core-swipe-navigation [manager]="submissions"> | ||||||
|     <ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshSubmission($event.target)"> |         <ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshSubmission($event.target)"> | ||||||
|         <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> |             <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||||
|     </ion-refresher> |         </ion-refresher> | ||||||
|     <core-loading [hideUntil]="loaded"> |         <core-loading [hideUntil]="loaded"> | ||||||
|         <addon-mod-assign-submission [courseId]="courseId" [moduleId]="moduleId" [submitId]="submitId" [blindId]="blindId"> |             <addon-mod-assign-submission *ngIf="loaded" | ||||||
|         </addon-mod-assign-submission> |                 [courseId]="courseId" [moduleId]="moduleId" [submitId]="submitId" [blindId]="blindId"> | ||||||
|     </core-loading> |             </addon-mod-assign-submission> | ||||||
|  |         </core-loading> | ||||||
|  |     </core-swipe-navigation> | ||||||
| </ion-content> | </ion-content> | ||||||
|  | |||||||
| @ -12,14 +12,17 @@ | |||||||
| // See the License for the specific language governing permissions and
 | // See the License for the specific language governing permissions and
 | ||||||
| // limitations under the License.
 | // limitations under the License.
 | ||||||
| 
 | 
 | ||||||
| import { Component, OnInit, ViewChild } from '@angular/core'; | import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; | ||||||
| import { ActivatedRoute } from '@angular/router'; | import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||||
|  | import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||||
|  | import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||||
| import { CoreCourse } from '@features/course/services/course'; | import { CoreCourse } from '@features/course/services/course'; | ||||||
| import { CanLeave } from '@guards/can-leave'; | import { CanLeave } from '@guards/can-leave'; | ||||||
| import { IonRefresher } from '@ionic/angular'; | import { IonRefresher } from '@ionic/angular'; | ||||||
| import { CoreNavigator } from '@services/navigator'; | import { CoreNavigator } from '@services/navigator'; | ||||||
| import { CoreScreen } from '@services/screen'; | import { CoreScreen } from '@services/screen'; | ||||||
| import { CoreDomUtils } from '@services/utils/dom'; | import { CoreDomUtils } from '@services/utils/dom'; | ||||||
|  | import { AddonModAssignSubmissionsSource } from '../../classes/submissions-source'; | ||||||
| import { AddonModAssignSubmissionComponent } from '../../components/submission/submission'; | import { AddonModAssignSubmissionComponent } from '../../components/submission/submission'; | ||||||
| import { AddonModAssign, AddonModAssignAssign } from '../../services/assign'; | import { AddonModAssign, AddonModAssignAssign } from '../../services/assign'; | ||||||
| 
 | 
 | ||||||
| @ -30,11 +33,12 @@ import { AddonModAssign, AddonModAssignAssign } from '../../services/assign'; | |||||||
|     selector: 'page-addon-mod-assign-submission-review', |     selector: 'page-addon-mod-assign-submission-review', | ||||||
|     templateUrl: 'submission-review.html', |     templateUrl: 'submission-review.html', | ||||||
| }) | }) | ||||||
| export class AddonModAssignSubmissionReviewPage implements OnInit, CanLeave { | export class AddonModAssignSubmissionReviewPage implements OnInit, OnDestroy, CanLeave { | ||||||
| 
 | 
 | ||||||
|     @ViewChild(AddonModAssignSubmissionComponent) submissionComponent?: AddonModAssignSubmissionComponent; |     @ViewChild(AddonModAssignSubmissionComponent) submissionComponent?: AddonModAssignSubmissionComponent; | ||||||
| 
 | 
 | ||||||
|     title = ''; // Title to display.
 |     title = ''; // Title to display.
 | ||||||
|  |     submissions?: AddonModAssignSubmissionSwipeItemsManager; | ||||||
|     moduleId!: number; // Module ID the submission belongs to.
 |     moduleId!: number; // Module ID the submission belongs to.
 | ||||||
|     courseId!: number; // Course ID the assignment belongs to.
 |     courseId!: number; // Course ID the assignment belongs to.
 | ||||||
|     submitId!: number; // User that did the submission.
 |     submitId!: number; // User that did the submission.
 | ||||||
| @ -46,9 +50,7 @@ export class AddonModAssignSubmissionReviewPage implements OnInit, CanLeave { | |||||||
|     protected blindMarking = false; // Whether it uses blind marking.
 |     protected blindMarking = false; // Whether it uses blind marking.
 | ||||||
|     protected forceLeave = false; // To allow leaving the page without checking for changes.
 |     protected forceLeave = false; // To allow leaving the page without checking for changes.
 | ||||||
| 
 | 
 | ||||||
|     constructor( |     constructor(protected route: ActivatedRoute) { } | ||||||
|         protected route: ActivatedRoute, |  | ||||||
|     ) { } |  | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Component being initialized. |      * Component being initialized. | ||||||
| @ -60,6 +62,19 @@ export class AddonModAssignSubmissionReviewPage implements OnInit, CanLeave { | |||||||
|                 this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); |                 this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||||
|                 this.submitId = CoreNavigator.getRouteNumberParam('submitId') || 0; |                 this.submitId = CoreNavigator.getRouteNumberParam('submitId') || 0; | ||||||
|                 this.blindId = CoreNavigator.getRouteNumberParam('blindId', { params }); |                 this.blindId = CoreNavigator.getRouteNumberParam('blindId', { params }); | ||||||
|  |                 const groupId = CoreNavigator.getRequiredRouteNumberParam('groupId'); | ||||||
|  |                 const selectedStatus = CoreNavigator.getRouteParam('selectedStatus'); | ||||||
|  |                 const submissionsSource = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||||
|  |                     AddonModAssignSubmissionsSource, | ||||||
|  |                     [this.courseId, this.moduleId, selectedStatus], | ||||||
|  |                 ); | ||||||
|  | 
 | ||||||
|  |                 this.submissions?.destroy(); | ||||||
|  | 
 | ||||||
|  |                 submissionsSource.groupId = groupId; | ||||||
|  |                 this.submissions = new AddonModAssignSubmissionSwipeItemsManager(submissionsSource); | ||||||
|  | 
 | ||||||
|  |                 this.submissions.start(); | ||||||
|             } catch (error) { |             } catch (error) { | ||||||
|                 CoreDomUtils.showErrorModal(error); |                 CoreDomUtils.showErrorModal(error); | ||||||
| 
 | 
 | ||||||
| @ -74,6 +89,13 @@ export class AddonModAssignSubmissionReviewPage implements OnInit, CanLeave { | |||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     ngOnDestroy(): void { | ||||||
|  |         this.submissions?.destroy(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * Check if we can leave the page or not. |      * Check if we can leave the page or not. | ||||||
|      * |      * | ||||||
| @ -190,3 +212,17 @@ export class AddonModAssignSubmissionReviewPage implements OnInit, CanLeave { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Helper to manage swiping within a collection of submissions. | ||||||
|  |  */ | ||||||
|  | class AddonModAssignSubmissionSwipeItemsManager extends CoreSwipeItemsManager { | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||||
|  |         return route.params.submitId; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | |||||||
							
								
								
									
										265
									
								
								src/addons/mod/forum/classes/forum-discussions-source.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										265
									
								
								src/addons/mod/forum/classes/forum-discussions-source.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,265 @@ | |||||||
|  | // (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 { Params } from '@angular/router'; | ||||||
|  | import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||||
|  | import { CoreUser } from '@features/user/services/user'; | ||||||
|  | import { | ||||||
|  |     AddonModForum, | ||||||
|  |     AddonModForumData, | ||||||
|  |     AddonModForumDiscussion, | ||||||
|  |     AddonModForumProvider, | ||||||
|  |     AddonModForumSortOrder, | ||||||
|  | } from '../services/forum'; | ||||||
|  | import { AddonModForumOffline, AddonModForumOfflineDiscussion } from '../services/forum-offline'; | ||||||
|  | 
 | ||||||
|  | export class AddonModForumDiscussionsSource extends CoreItemsManagerSource<AddonModForumDiscussionItem> { | ||||||
|  | 
 | ||||||
|  |     static readonly NEW_DISCUSSION: AddonModForumNewDiscussionForm = { newDiscussion: true }; | ||||||
|  | 
 | ||||||
|  |     readonly DISCUSSIONS_PATH_PREFIX: string; | ||||||
|  |     readonly COURSE_ID: number; | ||||||
|  |     readonly CM_ID: number; | ||||||
|  | 
 | ||||||
|  |     forum?: AddonModForumData; | ||||||
|  |     trackPosts = false; | ||||||
|  |     usesGroups = false; | ||||||
|  |     selectedSortOrder: AddonModForumSortOrder | null = null; | ||||||
|  | 
 | ||||||
|  |     constructor(courseId: number, cmId: number, discussionsPathPrefix: string) { | ||||||
|  |         super(); | ||||||
|  | 
 | ||||||
|  |         this.DISCUSSIONS_PATH_PREFIX = discussionsPathPrefix; | ||||||
|  |         this.COURSE_ID = courseId; | ||||||
|  |         this.CM_ID = cmId; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Type guard to infer NewDiscussionForm objects. | ||||||
|  |      * | ||||||
|  |      * @param discussion Item to check. | ||||||
|  |      * @return Whether the item is a new discussion form. | ||||||
|  |      */ | ||||||
|  |     isNewDiscussionForm(discussion: AddonModForumDiscussionItem): discussion is AddonModForumNewDiscussionForm { | ||||||
|  |         return 'newDiscussion' in discussion; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Type guard to infer AddonModForumDiscussion objects. | ||||||
|  |      * | ||||||
|  |      * @param discussion Item to check. | ||||||
|  |      * @return Whether the item is an online discussion. | ||||||
|  |      */ | ||||||
|  |     isOfflineDiscussion(discussion: AddonModForumDiscussionItem): discussion is AddonModForumOfflineDiscussion { | ||||||
|  |         return !this.isNewDiscussionForm(discussion) && !this.isOnlineDiscussion(discussion); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Type guard to infer AddonModForumDiscussion objects. | ||||||
|  |      * | ||||||
|  |      * @param discussion Item to check. | ||||||
|  |      * @return Whether the item is an online discussion. | ||||||
|  |      */ | ||||||
|  |     isOnlineDiscussion(discussion: AddonModForumDiscussionItem): discussion is AddonModForumDiscussion { | ||||||
|  |         return 'id' in discussion; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     getItemPath(discussion: AddonModForumDiscussionItem): string { | ||||||
|  |         if (this.isOnlineDiscussion(discussion)) { | ||||||
|  |             return this.DISCUSSIONS_PATH_PREFIX + discussion.discussion; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if (this.isOfflineDiscussion(discussion)) { | ||||||
|  |             return `${this.DISCUSSIONS_PATH_PREFIX}new/${discussion.timecreated}`; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return `${this.DISCUSSIONS_PATH_PREFIX}new/0`; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     getItemQueryParams(discussion: AddonModForumDiscussionItem): Params { | ||||||
|  |         return { | ||||||
|  |             courseId: this.COURSE_ID, | ||||||
|  |             cmId: this.CM_ID, | ||||||
|  |             forumId: this.forum?.id, | ||||||
|  |             ...(this.isOnlineDiscussion(discussion) ? { discussion, trackPosts: this.trackPosts } : {}), | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     getPagesLoaded(): number { | ||||||
|  |         if (this.items === null) { | ||||||
|  |             return 0; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const onlineEntries = this.items.filter(item => this.isOnlineDiscussion(item)); | ||||||
|  | 
 | ||||||
|  |         return Math.ceil(onlineEntries.length / this.getPageLength()); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     getPageLength(): number { | ||||||
|  |         return AddonModForumProvider.DISCUSSIONS_PER_PAGE; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Load forum. | ||||||
|  |      */ | ||||||
|  |     async loadForum(): Promise<void> { | ||||||
|  |         this.forum = await AddonModForum.getForum(this.COURSE_ID, this.CM_ID); | ||||||
|  | 
 | ||||||
|  |         if (typeof this.forum.istracked != 'undefined') { | ||||||
|  |             this.trackPosts = this.forum.istracked; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     protected async loadPageItems(page: number): Promise<{ items: AddonModForumDiscussionItem[]; hasMoreItems: boolean }> { | ||||||
|  |         const discussions: AddonModForumDiscussionItem[] = []; | ||||||
|  | 
 | ||||||
|  |         if (page === 0) { | ||||||
|  |             const offlineDiscussions = await this.loadOfflineDiscussions(); | ||||||
|  | 
 | ||||||
|  |             discussions.push(AddonModForumDiscussionsSource.NEW_DISCUSSION); | ||||||
|  |             discussions.push(...offlineDiscussions); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const { discussions: onlineDiscussions, canLoadMore } = await this.loadOnlineDiscussions(page); | ||||||
|  | 
 | ||||||
|  |         discussions.push(...onlineDiscussions); | ||||||
|  | 
 | ||||||
|  |         return { | ||||||
|  |             items: discussions, | ||||||
|  |             hasMoreItems: canLoadMore, | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Load online discussions for the given page. | ||||||
|  |      * | ||||||
|  |      * @param page Page. | ||||||
|  |      * @returns Online discussions info. | ||||||
|  |      */ | ||||||
|  |     private async loadOnlineDiscussions(page: number): Promise<{ | ||||||
|  |         discussions: AddonModForumDiscussionItem[]; | ||||||
|  |         canLoadMore: boolean; | ||||||
|  |     }> { | ||||||
|  |         if (!this.forum || !this.selectedSortOrder) { | ||||||
|  |             throw new Error('Can\'t load discussions without a forum or selected sort order'); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const response = await AddonModForum.getDiscussions(this.forum.id, { | ||||||
|  |             cmId: this.forum.cmid, | ||||||
|  |             sortOrder: this.selectedSortOrder.value, | ||||||
|  |             page, | ||||||
|  |         }); | ||||||
|  |         let discussions = response.discussions; | ||||||
|  | 
 | ||||||
|  |         if (this.usesGroups) { | ||||||
|  |             discussions = await AddonModForum.formatDiscussionsGroups(this.forum.cmid, discussions); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Hide author for first post and type single.
 | ||||||
|  |         if (this.forum.type === 'single') { | ||||||
|  |             for (const discussion of discussions) { | ||||||
|  |                 if (discussion.userfullname && discussion.parent === 0) { | ||||||
|  |                     discussion.userfullname = false; | ||||||
|  |                     break; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // If any discussion has unread posts, the whole forum is being tracked.
 | ||||||
|  |         if (typeof this.forum.istracked === 'undefined' && !this.trackPosts) { | ||||||
|  |             for (const discussion of discussions) { | ||||||
|  |                 if (discussion.numunread > 0) { | ||||||
|  |                     this.trackPosts = true; | ||||||
|  |                     break; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return { discussions, canLoadMore: response.canLoadMore }; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Load offline discussions. | ||||||
|  |      * | ||||||
|  |      * @returns Offline discussions. | ||||||
|  |      */ | ||||||
|  |     private async loadOfflineDiscussions(): Promise<AddonModForumOfflineDiscussion[]> { | ||||||
|  |         if (!this.forum) { | ||||||
|  |             throw new Error('Can\'t load discussions without a forum'); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const forum = this.forum; | ||||||
|  |         let offlineDiscussions = await AddonModForumOffline.getNewDiscussions(forum.id); | ||||||
|  | 
 | ||||||
|  |         if (offlineDiscussions.length === 0) { | ||||||
|  |             return []; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if (this.usesGroups) { | ||||||
|  |             offlineDiscussions = await AddonModForum.formatDiscussionsGroups(forum.cmid, offlineDiscussions); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Fill user data for Offline discussions (should be already cached).
 | ||||||
|  |         const promises = offlineDiscussions.map(async (offlineDiscussion) => { | ||||||
|  |             const discussion = offlineDiscussion as unknown as AddonModForumDiscussion; | ||||||
|  | 
 | ||||||
|  |             if (discussion.parent === 0 || forum.type === 'single') { | ||||||
|  |                 // Do not show author for first post and type single.
 | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             try { | ||||||
|  |                 const user = await CoreUser.getProfile(discussion.userid, this.COURSE_ID, true); | ||||||
|  | 
 | ||||||
|  |                 discussion.userfullname = user.fullname; | ||||||
|  |                 discussion.userpictureurl = user.profileimageurl; | ||||||
|  |             } catch (error) { | ||||||
|  |                 // Ignore errors.
 | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         await Promise.all(promises); | ||||||
|  | 
 | ||||||
|  |         // Sort discussion by time (newer first).
 | ||||||
|  |         offlineDiscussions.sort((a, b) => b.timecreated - a.timecreated); | ||||||
|  | 
 | ||||||
|  |         return offlineDiscussions; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Type to select the new discussion form. | ||||||
|  |  */ | ||||||
|  | export type AddonModForumNewDiscussionForm = { newDiscussion: true }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Type of items that can be held by the discussions manager. | ||||||
|  |  */ | ||||||
|  | export type AddonModForumDiscussionItem = AddonModForumDiscussion | AddonModForumOfflineDiscussion | AddonModForumNewDiscussionForm; | ||||||
| @ -0,0 +1,52 @@ | |||||||
|  | // (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 { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||||
|  | import { AddonModForumDiscussionItem, AddonModForumDiscussionsSource } from './forum-discussions-source'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Helper to manage swiping within a collection of discussions. | ||||||
|  |  */ | ||||||
|  | export class AddonModForumDiscussionsSwipeManager | ||||||
|  |     extends CoreSwipeItemsManager<AddonModForumDiscussionItem, AddonModForumDiscussionsSource> { | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     async navigateToNextItem(): Promise<void> { | ||||||
|  |         let delta = -1; | ||||||
|  |         const item = await this.getItemBy(-1); | ||||||
|  | 
 | ||||||
|  |         if (item && this.getSource().isNewDiscussionForm(item)) { | ||||||
|  |             delta--; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         await this.navigateToItemBy(delta, 'back'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     async navigateToPreviousItem(): Promise<void> { | ||||||
|  |         let delta = 1; | ||||||
|  |         const item = await this.getItemBy(1); | ||||||
|  | 
 | ||||||
|  |         if (item && this.getSource().isNewDiscussionForm(item)) { | ||||||
|  |             delta++; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         await this.navigateToItemBy(delta, 'forward'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -10,11 +10,11 @@ | |||||||
|         <core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}" iconAction="far-newspaper" |         <core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}" iconAction="far-newspaper" | ||||||
|             (action)="gotoBlog()"> |             (action)="gotoBlog()"> | ||||||
|         </core-context-menu-item> |         </core-context-menu-item> | ||||||
|         <core-context-menu-item *ngIf="discussions.loaded && !(hasOffline || hasOfflineRatings) && isOnline" [priority]="700" |         <core-context-menu-item *ngIf="discussions && discussions.loaded && !(hasOffline || hasOfflineRatings) && isOnline" [priority]="700" | ||||||
|             [content]="'addon.mod_forum.refreshdiscussions' | translate" [iconAction]="refreshIcon" [closeOnClick]="false" |             [content]="'addon.mod_forum.refreshdiscussions' | translate" [iconAction]="refreshIcon" [closeOnClick]="false" | ||||||
|             (action)="doRefresh(null, $event)"> |             (action)="doRefresh(null, $event)"> | ||||||
|         </core-context-menu-item> |         </core-context-menu-item> | ||||||
|         <core-context-menu-item *ngIf="discussions.loaded && (hasOffline || hasOfflineRatings) && isOnline" [priority]="600" |         <core-context-menu-item *ngIf="discussions && discussions.loaded && (hasOffline || hasOfflineRatings) && isOnline" [priority]="600" | ||||||
|             [content]="'core.settings.synchronizenow' | translate" [iconAction]="syncIcon" [closeOnClick]="false" |             [content]="'core.settings.synchronizenow' | translate" [iconAction]="syncIcon" [closeOnClick]="false" | ||||||
|             (action)="doRefresh(null, $event, true)"> |             (action)="doRefresh(null, $event, true)"> | ||||||
|         </core-context-menu-item> |         </core-context-menu-item> | ||||||
| @ -32,11 +32,11 @@ | |||||||
| 
 | 
 | ||||||
| <!-- Content. --> | <!-- Content. --> | ||||||
| <core-split-view> | <core-split-view> | ||||||
|     <ion-refresher slot="fixed" [disabled]="!discussions.loaded" (ionRefresh)="doRefresh($event.target)"> |     <ion-refresher slot="fixed" [disabled]="discussions && !discussions.loaded" (ionRefresh)="doRefresh($event.target)"> | ||||||
|         <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> |         <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||||
|     </ion-refresher> |     </ion-refresher> | ||||||
| 
 | 
 | ||||||
|     <core-loading [hideUntil]="discussions.loaded"> |     <core-loading [hideUntil]="discussions && discussions.loaded"> | ||||||
|         <!-- Activity info. --> |         <!-- Activity info. --> | ||||||
|         <core-course-module-info [module]="module" (completionChanged)="onCompletionChange()" |         <core-course-module-info [module]="module" (completionChanged)="onCompletionChange()" | ||||||
|             [description]="forum && forum.type != 'single' && description" [component]="component" [componentId]="componentId" |             [description]="forum && forum.type != 'single' && description" [component]="component" [componentId]="componentId" | ||||||
| @ -57,17 +57,18 @@ | |||||||
|         </ion-card> |         </ion-card> | ||||||
| 
 | 
 | ||||||
|         <ng-container *ngIf="forum"> |         <ng-container *ngIf="forum"> | ||||||
|             <core-empty-box *ngIf="discussions.empty" icon="far-comments" [message]="'addon.mod_forum.forumnodiscussionsyet' | translate"> |             <core-empty-box *ngIf="!discussions || discussions.empty" icon="far-comments" | ||||||
|  |                 [message]="'addon.mod_forum.forumnodiscussionsyet' | translate"> | ||||||
|             </core-empty-box> |             </core-empty-box> | ||||||
| 
 | 
 | ||||||
|             <div *ngIf="!discussions.empty && sortingAvailable && selectedSortOrder" class="ion-text-wrap"> |             <div *ngIf="discussions && !discussions.empty && sortingAvailable && selectedSortOrder" class="ion-text-wrap"> | ||||||
|                 <core-combobox [modalOptions]="sortOrderSelectorModalOptions" listboxId="addon-mod-forum-sort-selector" |                 <core-combobox [modalOptions]="sortOrderSelectorModalOptions" listboxId="addon-mod-forum-sort-selector" | ||||||
|                     [label]="('core.sort' | translate)" (onChange)="setSortOrder($event)" [selection]="selectedSortOrder.label | translate" |                     [label]="('core.sort' | translate)" (onChange)="setSortOrder($event)" [selection]="selectedSortOrder.label | translate" | ||||||
|                     interface="modal"> |                     interface="modal"> | ||||||
|                 </core-combobox> |                 </core-combobox> | ||||||
|             </div> |             </div> | ||||||
| 
 | 
 | ||||||
|             <ion-item *ngFor="let discussion of discussions.items" class="addon-mod-forum-discussion" detail="true" |             <ion-item *ngFor="let discussion of discussionsItems" class="addon-mod-forum-discussion" detail="true" | ||||||
|                 [lines]="discussion.groupname && 'none'" [attr.aria-current]="discussions.getItemAriaCurrent(discussion)" |                 [lines]="discussion.groupname && 'none'" [attr.aria-current]="discussions.getItemAriaCurrent(discussion)" | ||||||
|                 (click)="discussions.select(discussion)" button> |                 (click)="discussions.select(discussion)" button> | ||||||
|                 <ion-label> |                 <ion-label> | ||||||
| @ -96,17 +97,16 @@ | |||||||
|                                 <ion-icon name="fas-users" [attr.aria-label]="'addon.mod_forum.group' | translate"> |                                 <ion-icon name="fas-users" [attr.aria-label]="'addon.mod_forum.group' | translate"> | ||||||
|                                 </ion-icon> {{ discussion.groupname }} |                                 </ion-icon> {{ discussion.groupname }} | ||||||
|                             </p> |                             </p> | ||||||
|                             <p *ngIf="discussions.isOnlineDiscussion(discussion)"> |                             <p *ngIf="isOnlineDiscussion(discussion)"> | ||||||
|                                 {{discussion.created * 1000 | coreFormatDate: "strftimerecentfull"}} |                                 {{discussion.created * 1000 | coreFormatDate: "strftimerecentfull"}} | ||||||
|                             </p> |                             </p> | ||||||
|                             <p *ngIf="discussions.isOfflineDiscussion(discussion)"> |                             <p *ngIf="isOfflineDiscussion(discussion)"> | ||||||
|                                 <ion-icon name="fas-clock" aria-hidden="true"></ion-icon> |                                 <ion-icon name="fas-clock" aria-hidden="true"></ion-icon> | ||||||
|                                 {{ 'core.notsent' | translate }} |                                 {{ 'core.notsent' | translate }} | ||||||
|                             </p> |                             </p> | ||||||
|                         </div> |                         </div> | ||||||
|                     </div> |                     </div> | ||||||
|                     <ion-row *ngIf="discussions.isOnlineDiscussion(discussion)" |                     <ion-row *ngIf="isOnlineDiscussion(discussion)" class="ion-text-center addon-mod-forum-discussion-more-info"> | ||||||
|                         class="ion-text-center addon-mod-forum-discussion-more-info"> |  | ||||||
|                         <ion-col class="ion-text-start"> |                         <ion-col class="ion-text-start"> | ||||||
|                             <ion-note> |                             <ion-note> | ||||||
|                                 <ion-icon name="fas-clock" aria-hidden="true"></ion-icon> {{ 'addon.mod_forum.lastpost' | translate }} |                                 <ion-icon name="fas-clock" aria-hidden="true"></ion-icon> {{ 'addon.mod_forum.lastpost' | translate }} | ||||||
| @ -134,7 +134,7 @@ | |||||||
|                 </ion-label> |                 </ion-label> | ||||||
|             </ion-item> |             </ion-item> | ||||||
| 
 | 
 | ||||||
|             <core-infinite-loading [enabled]="discussions.onlineLoaded && !discussions.completed" [error]="discussions.fetchFailed" |             <core-infinite-loading [enabled]="discussions && discussions.loaded && !discussions.completed" [error]="fetchFailed" | ||||||
|                 (action)="fetchMoreDiscussions($event)"> |                 (action)="fetchMoreDiscussions($event)"> | ||||||
|             </core-infinite-loading> |             </core-infinite-loading> | ||||||
|         </ng-container> |         </ng-container> | ||||||
|  | |||||||
| @ -13,7 +13,7 @@ | |||||||
| // limitations under the License.
 | // limitations under the License.
 | ||||||
| 
 | 
 | ||||||
| import { Component, Optional, OnInit, OnDestroy, ViewChild, AfterViewInit } from '@angular/core'; | import { Component, Optional, OnInit, OnDestroy, ViewChild, AfterViewInit } from '@angular/core'; | ||||||
| import { ActivatedRoute, Params } from '@angular/router'; | import { ActivatedRoute } from '@angular/router'; | ||||||
| import { IonContent } from '@ionic/angular'; | import { IonContent } from '@ionic/angular'; | ||||||
| import { ModalOptions } from '@ionic/core'; | import { ModalOptions } from '@ionic/core'; | ||||||
| 
 | 
 | ||||||
| @ -27,7 +27,7 @@ import { | |||||||
|     AddonModForumNewDiscussionData, |     AddonModForumNewDiscussionData, | ||||||
|     AddonModForumReplyDiscussionData, |     AddonModForumReplyDiscussionData, | ||||||
| } from '@addons/mod/forum/services/forum'; | } from '@addons/mod/forum/services/forum'; | ||||||
| import { AddonModForumOffline, AddonModForumOfflineDiscussion } from '@addons/mod/forum/services/forum-offline'; | import { AddonModForumOffline } from '@addons/mod/forum/services/forum-offline'; | ||||||
| import { Translate } from '@singletons'; | import { Translate } from '@singletons'; | ||||||
| import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; | import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; | ||||||
| import { AddonModForumHelper } from '@addons/mod/forum/services/forum-helper'; | import { AddonModForumHelper } from '@addons/mod/forum/services/forum-helper'; | ||||||
| @ -44,7 +44,6 @@ import { CoreUser } from '@features/user/services/user'; | |||||||
| import { CoreDomUtils } from '@services/utils/dom'; | import { CoreDomUtils } from '@services/utils/dom'; | ||||||
| import { CoreUtils } from '@services/utils/utils'; | import { CoreUtils } from '@services/utils/utils'; | ||||||
| import { CoreCourse } from '@features/course/services/course'; | import { CoreCourse } from '@features/course/services/course'; | ||||||
| import { CorePageItemsListManager } from '@classes/page-items-list-manager'; |  | ||||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||||
| import { AddonModForumDiscussionOptionsMenuComponent } from '../discussion-options-menu/discussion-options-menu'; | import { AddonModForumDiscussionOptionsMenuComponent } from '../discussion-options-menu/discussion-options-menu'; | ||||||
| import { AddonModForumSortOrderSelectorComponent } from '../sort-order-selector/sort-order-selector'; | import { AddonModForumSortOrderSelectorComponent } from '../sort-order-selector/sort-order-selector'; | ||||||
| @ -56,6 +55,9 @@ import { CoreRatingProvider } from '@features/rating/services/rating'; | |||||||
| import { CoreRatingSyncProvider } from '@features/rating/services/rating-sync'; | import { CoreRatingSyncProvider } from '@features/rating/services/rating-sync'; | ||||||
| import { CoreRatingOffline } from '@features/rating/services/rating-offline'; | import { CoreRatingOffline } from '@features/rating/services/rating-offline'; | ||||||
| import { ContextLevel } from '@/core/constants'; | import { ContextLevel } from '@/core/constants'; | ||||||
|  | import { AddonModForumDiscussionItem, AddonModForumDiscussionsSource } from '../../classes/forum-discussions-source'; | ||||||
|  | import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; | ||||||
|  | import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Component that displays a forum entry page. |  * Component that displays a forum entry page. | ||||||
| @ -72,24 +74,21 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|     component = AddonModForumProvider.COMPONENT; |     component = AddonModForumProvider.COMPONENT; | ||||||
|     moduleName = 'forum'; |     moduleName = 'forum'; | ||||||
|     descriptionNote?: string; |     descriptionNote?: string; | ||||||
|     forum?: AddonModForumData; |     discussions!: AddonModForumDiscussionsManager; | ||||||
|     discussions: AddonModForumDiscussionsManager; |     discussionsItems: AddonModForumDiscussionItem[] = []; | ||||||
|  |     fetchFailed = false; | ||||||
|     canAddDiscussion = false; |     canAddDiscussion = false; | ||||||
|     addDiscussionText!: string; |     addDiscussionText!: string; | ||||||
|     availabilityMessage: string | null = null; |     availabilityMessage: string | null = null; | ||||||
|     sortingAvailable!: boolean; |     sortingAvailable!: boolean; | ||||||
|     sortOrders: AddonModForumSortOrder[] = []; |     sortOrders: AddonModForumSortOrder[] = []; | ||||||
|     selectedSortOrder: AddonModForumSortOrder | null = null; |  | ||||||
|     canPin = false; |     canPin = false; | ||||||
|     trackPosts = false; |  | ||||||
|     hasOfflineRatings = false; |     hasOfflineRatings = false; | ||||||
|     sortOrderSelectorModalOptions: ModalOptions = { |     sortOrderSelectorModalOptions: ModalOptions = { | ||||||
|         component: AddonModForumSortOrderSelectorComponent, |         component: AddonModForumSortOrderSelectorComponent, | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED; |     protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED; | ||||||
|     protected page = 0; |  | ||||||
|     protected usesGroups = false; |  | ||||||
|     protected syncManualObserver?: CoreEventObserver; // It will observe the sync manual event.
 |     protected syncManualObserver?: CoreEventObserver; // It will observe the sync manual event.
 | ||||||
|     protected replyObserver?: CoreEventObserver; |     protected replyObserver?: CoreEventObserver; | ||||||
|     protected newDiscObserver?: CoreEventObserver; |     protected newDiscObserver?: CoreEventObserver; | ||||||
| @ -97,19 +96,42 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|     protected changeDiscObserver?: CoreEventObserver; |     protected changeDiscObserver?: CoreEventObserver; | ||||||
|     protected ratingOfflineObserver?: CoreEventObserver; |     protected ratingOfflineObserver?: CoreEventObserver; | ||||||
|     protected ratingSyncObserver?: CoreEventObserver; |     protected ratingSyncObserver?: CoreEventObserver; | ||||||
|  |     protected sourceUnsubscribe?: () => void; | ||||||
| 
 | 
 | ||||||
|     constructor( |     constructor( | ||||||
|         route: ActivatedRoute, |         public route: ActivatedRoute, | ||||||
|         @Optional() protected content?: IonContent, |         @Optional() protected content?: IonContent, | ||||||
|         @Optional() courseContentsPage?: CoreCourseContentsPage, |         @Optional() courseContentsPage?: CoreCourseContentsPage, | ||||||
|     ) { |     ) { | ||||||
|         super('AddonModForumIndexComponent', content, courseContentsPage); |         super('AddonModForumIndexComponent', content, courseContentsPage); | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|         this.discussions = new AddonModForumDiscussionsManager( |     get forum(): AddonModForumData | undefined { | ||||||
|             route.component, |         return this.discussions?.getSource().forum; | ||||||
|             this, |     } | ||||||
|             courseContentsPage ? `${AddonModForumModuleHandlerService.PAGE_NAME}/` : '', | 
 | ||||||
|         ); |     get selectedSortOrder(): AddonModForumSortOrder | undefined { | ||||||
|  |         return this.discussions?.getSource().selectedSortOrder ?? undefined; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Check whether a discussion is online. | ||||||
|  |      * | ||||||
|  |      * @param discussion Discussion | ||||||
|  |      * @return Whether the discussion is online. | ||||||
|  |      */ | ||||||
|  |     isOnlineDiscussion(discussion: AddonModForumDiscussionItem): boolean { | ||||||
|  |         return this.discussions && this.discussions.getSource().isOnlineDiscussion(discussion); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Check whether a discussion is offline. | ||||||
|  |      * | ||||||
|  |      * @param discussion Discussion | ||||||
|  |      * @return Whether the discussion is offline. | ||||||
|  |      */ | ||||||
|  |     isOfflineDiscussion(discussion: AddonModForumDiscussionItem): boolean { | ||||||
|  |         return this.discussions && this.discussions.getSource().isOfflineDiscussion(discussion); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -126,6 +148,48 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
| 
 | 
 | ||||||
|         await super.ngOnInit(); |         await super.ngOnInit(); | ||||||
| 
 | 
 | ||||||
|  |         // Initialize discussions manager.
 | ||||||
|  |         const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||||
|  |             AddonModForumDiscussionsSource, | ||||||
|  |             [this.courseId, this.module.id, this.courseContentsPage ? `${AddonModForumModuleHandlerService.PAGE_NAME}/` : ''], | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         this.sourceUnsubscribe = source.addListener({ | ||||||
|  |             onItemsUpdated: async discussions => { | ||||||
|  |                 this.discussionsItems = discussions.filter(discussion => !source.isNewDiscussionForm(discussion)); | ||||||
|  | 
 | ||||||
|  |                 if (!this.forum) { | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 // Check if there are replies for discussions stored in offline.
 | ||||||
|  |                 const hasOffline = await AddonModForumOffline.hasForumReplies(this.forum.id); | ||||||
|  | 
 | ||||||
|  |                 this.hasOffline = this.hasOffline || hasOffline; | ||||||
|  | 
 | ||||||
|  |                 if (hasOffline) { | ||||||
|  |                     // Only update new fetched discussions.
 | ||||||
|  |                     const promises = discussions.map(async (discussion) => { | ||||||
|  |                         if (!this.discussions.getSource().isOnlineDiscussion(discussion)) { | ||||||
|  |                             return; | ||||||
|  |                         } | ||||||
|  | 
 | ||||||
|  |                         // Get offline discussions.
 | ||||||
|  |                         const replies = await AddonModForumOffline.getDiscussionReplies(discussion.discussion); | ||||||
|  | 
 | ||||||
|  |                         discussion.numreplies = Number(discussion.numreplies) + replies.length; | ||||||
|  |                     }); | ||||||
|  | 
 | ||||||
|  |                     await Promise.all(promises); | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             onReset: () => { | ||||||
|  |                 this.discussionsItems = []; | ||||||
|  |             }, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         this.discussions = new AddonModForumDiscussionsManager(source, this); | ||||||
|  | 
 | ||||||
|         // Refresh data if this forum discussion is synchronized from discussions list.
 |         // Refresh data if this forum discussion is synchronized from discussions list.
 | ||||||
|         this.syncManualObserver = CoreEvents.on(AddonModForumSyncProvider.MANUAL_SYNCED, (data) => { |         this.syncManualObserver = CoreEvents.on(AddonModForumSyncProvider.MANUAL_SYNCED, (data) => { | ||||||
|             this.autoSyncEventReceived(data); |             this.autoSyncEventReceived(data); | ||||||
| @ -141,12 +205,16 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|             this.eventReceived.bind(this, false), |             this.eventReceived.bind(this, false), | ||||||
|         ); |         ); | ||||||
|         this.changeDiscObserver = CoreEvents.on(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data => { |         this.changeDiscObserver = CoreEvents.on(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data => { | ||||||
|             if ((this.forum && this.forum.id === data.forumId) || data.cmId === this.module.id) { |             if (!this.forum) { | ||||||
|                 AddonModForum.invalidateDiscussionsList(this.forum!.id).finally(() => { |                 return; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if (this.forum.id === data.forumId || data.cmId === this.module.id) { | ||||||
|  |                 AddonModForum.invalidateDiscussionsList(this.forum.id).finally(() => { | ||||||
|                     if (data.discussionId) { |                     if (data.discussionId) { | ||||||
|                         // Discussion changed, search it in the list of discussions.
 |                         // Discussion changed, search it in the list of discussions.
 | ||||||
|                         const discussion = this.discussions.items.find( |                         const discussion = this.discussions.items.find( | ||||||
|                             (disc) => this.discussions.isOnlineDiscussion(disc) && data.discussionId == disc.discussion, |                             (disc) => this.discussions.getSource().isOnlineDiscussion(disc) && data.discussionId == disc.discussion, | ||||||
|                         ) as AddonModForumDiscussion; |                         ) as AddonModForumDiscussion; | ||||||
| 
 | 
 | ||||||
|                         if (discussion) { |                         if (discussion) { | ||||||
| @ -196,20 +264,6 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|     async ngAfterViewInit(): Promise<void> { |     async ngAfterViewInit(): Promise<void> { | ||||||
|         await this.loadContent(false, true); |         await this.loadContent(false, true); | ||||||
| 
 | 
 | ||||||
|         if (!this.forum) { |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         CoreUtils.ignoreErrors( |  | ||||||
|             AddonModForum.instance |  | ||||||
|                 .logView(this.forum.id, this.forum.name) |  | ||||||
|                 .then(async () => { |  | ||||||
|                     CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); |  | ||||||
| 
 |  | ||||||
|                     return; |  | ||||||
|                 }), |  | ||||||
|         ); |  | ||||||
| 
 |  | ||||||
|         this.discussions.start(this.splitView); |         this.discussions.start(this.splitView); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -226,6 +280,8 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|         this.changeDiscObserver && this.changeDiscObserver.off(); |         this.changeDiscObserver && this.changeDiscObserver.off(); | ||||||
|         this.ratingOfflineObserver && this.ratingOfflineObserver.off(); |         this.ratingOfflineObserver && this.ratingOfflineObserver.off(); | ||||||
|         this.ratingSyncObserver && this.ratingSyncObserver.off(); |         this.ratingSyncObserver && this.ratingSyncObserver.off(); | ||||||
|  |         this.sourceUnsubscribe && this.sourceUnsubscribe(); | ||||||
|  |         this.discussions.destroy(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -236,19 +292,21 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|      * @param showErrors Wether to show errors to the user or hide them. |      * @param showErrors Wether to show errors to the user or hide them. | ||||||
|      */ |      */ | ||||||
|     protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> { |     protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> { | ||||||
|         this.discussions.fetchFailed = false; |         this.fetchFailed = false; | ||||||
| 
 |  | ||||||
|         const promises: Promise<void>[] = []; |  | ||||||
| 
 |  | ||||||
|         promises.push(this.fetchForum(sync, showErrors)); |  | ||||||
|         promises.push(this.fetchSortOrderPreference()); |  | ||||||
| 
 | 
 | ||||||
|         try { |         try { | ||||||
|             await Promise.all(promises); |  | ||||||
|             await Promise.all([ |             await Promise.all([ | ||||||
|                 this.fetchOfflineDiscussions(), |                 this.fetchForum(sync, showErrors), | ||||||
|                 this.fetchDiscussions(refresh), |                 this.fetchSortOrderPreference(), | ||||||
|                 CoreRatingOffline.hasRatings('mod_forum', 'post', ContextLevel.MODULE, this.forum!.cmid).then((hasRatings) => { |             ]); | ||||||
|  | 
 | ||||||
|  |             if (!this.forum) { | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             await Promise.all([ | ||||||
|  |                 refresh ? this.discussions.reload() : this.discussions.load(), | ||||||
|  |                 CoreRatingOffline.hasRatings('mod_forum', 'post', ContextLevel.MODULE, this.forum.cmid).then((hasRatings) => { | ||||||
|                     this.hasOfflineRatings = hasRatings; |                     this.hasOfflineRatings = hasRatings; | ||||||
| 
 | 
 | ||||||
|                     return; |                     return; | ||||||
| @ -258,7 +316,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|             if (refresh) { |             if (refresh) { | ||||||
|                 CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true); |                 CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true); | ||||||
| 
 | 
 | ||||||
|                 this.discussions.fetchFailed = true; // Set to prevent infinite calls with infinite-loading.
 |                 this.fetchFailed = true; // Set to prevent infinite calls with infinite-loading.
 | ||||||
|             } else { |             } else { | ||||||
|                 // Get forum failed, retry without using cache since it might be a new activity.
 |                 // Get forum failed, retry without using cache since it might be a new activity.
 | ||||||
|                 await this.refreshContent(sync); |                 await this.refreshContent(sync); | ||||||
| @ -273,19 +331,19 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         const forum = await AddonModForum.getForum(this.courseId, this.module.id); |         await this.discussions.getSource().loadForum(); | ||||||
| 
 | 
 | ||||||
|         this.forum = forum; |         if (!this.forum) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const forum = this.forum; | ||||||
|         this.description = forum.intro || this.description; |         this.description = forum.intro || this.description; | ||||||
|         this.availabilityMessage = AddonModForumHelper.getAvailabilityMessage(forum); |         this.availabilityMessage = AddonModForumHelper.getAvailabilityMessage(forum); | ||||||
|         this.descriptionNote = Translate.instant('addon.mod_forum.numdiscussions', { |         this.descriptionNote = Translate.instant('addon.mod_forum.numdiscussions', { | ||||||
|             numdiscussions: forum.numdiscussions, |             numdiscussions: forum.numdiscussions, | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         if (typeof forum.istracked != 'undefined') { |  | ||||||
|             this.trackPosts = forum.istracked; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         this.dataRetrieved.emit(forum); |         this.dataRetrieved.emit(forum); | ||||||
| 
 | 
 | ||||||
|         switch (forum.type) { |         switch (forum.type) { | ||||||
| @ -319,10 +377,10 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|         // Check if the activity uses groups.
 |         // Check if the activity uses groups.
 | ||||||
|         promises.push( |         promises.push( | ||||||
|             CoreGroups.instance |             CoreGroups.instance | ||||||
|                 .getActivityGroupMode(this.forum.cmid) |                 .getActivityGroupMode(forum.cmid) | ||||||
|                 .then(async mode => { |                 .then(async mode => { | ||||||
|                     this.usesGroups = mode === CoreGroupsProvider.SEPARATEGROUPS |                     this.discussions.getSource().usesGroups = | ||||||
|                                     || mode === CoreGroupsProvider.VISIBLEGROUPS; |                         mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS; | ||||||
| 
 | 
 | ||||||
|                     return; |                     return; | ||||||
|                 }), |                 }), | ||||||
| @ -330,14 +388,14 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
| 
 | 
 | ||||||
|         promises.push( |         promises.push( | ||||||
|             AddonModForum.instance |             AddonModForum.instance | ||||||
|                 .getAccessInformation(this.forum.id, { cmId: this.module.id }) |                 .getAccessInformation(forum.id, { cmId: this.module.id }) | ||||||
|                 .then(async accessInfo => { |                 .then(async accessInfo => { | ||||||
|                     // Disallow adding discussions if cut-off date is reached and the user has not the
 |                     // Disallow adding discussions if cut-off date is reached and the user has not the
 | ||||||
|                     // capability to override it.
 |                     // capability to override it.
 | ||||||
|                     // Just in case the forum was fetched from WS when the cut-off date was not reached but it is now.
 |                     // Just in case the forum was fetched from WS when the cut-off date was not reached but it is now.
 | ||||||
|                     const cutoffDateReached = AddonModForumHelper.isCutoffDateReached(this.forum!) |                     const cutoffDateReached = AddonModForumHelper.isCutoffDateReached(forum) | ||||||
|                                     && !accessInfo.cancanoverridecutoff; |                                     && !accessInfo.cancanoverridecutoff; | ||||||
|                     this.canAddDiscussion = !!this.forum?.cancreatediscussions && !cutoffDateReached; |                     this.canAddDiscussion = !!forum.cancreatediscussions && !cutoffDateReached; | ||||||
| 
 | 
 | ||||||
|                     return; |                     return; | ||||||
|                 }), |                 }), | ||||||
| @ -347,7 +405,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|             // Use the canAddDiscussion WS to check if the user can pin discussions.
 |             // Use the canAddDiscussion WS to check if the user can pin discussions.
 | ||||||
|             promises.push( |             promises.push( | ||||||
|                 AddonModForum.instance |                 AddonModForum.instance | ||||||
|                     .canAddDiscussionToAll(this.forum.id, { cmId: this.module.id }) |                     .canAddDiscussionToAll(forum.id, { cmId: this.module.id }) | ||||||
|                     .then(async response => { |                     .then(async response => { | ||||||
|                         this.canPin = !!response.canpindiscussions; |                         this.canPin = !!response.canpindiscussions; | ||||||
| 
 | 
 | ||||||
| @ -366,124 +424,6 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|         await Promise.all(promises); |         await Promise.all(promises); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |  | ||||||
|      * Convenience function to fetch offline discussions. |  | ||||||
|      * |  | ||||||
|      * @return Promise resolved when done. |  | ||||||
|      */ |  | ||||||
|     protected async fetchOfflineDiscussions(): Promise<void> { |  | ||||||
|         const forum = this.forum!; |  | ||||||
|         let offlineDiscussions = await AddonModForumOffline.getNewDiscussions(forum.id); |  | ||||||
|         this.hasOffline = !!offlineDiscussions.length; |  | ||||||
| 
 |  | ||||||
|         if (!this.hasOffline) { |  | ||||||
|             this.discussions.setOfflineDiscussions([]); |  | ||||||
| 
 |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if (this.usesGroups) { |  | ||||||
|             offlineDiscussions = await AddonModForum.formatDiscussionsGroups(forum.cmid, offlineDiscussions); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // Fill user data for Offline discussions (should be already cached).
 |  | ||||||
|         const promises = offlineDiscussions.map(async (offlineDiscussion) => { |  | ||||||
|             const discussion = offlineDiscussion as unknown as AddonModForumDiscussion; |  | ||||||
| 
 |  | ||||||
|             if (discussion.parent === 0 || forum.type === 'single') { |  | ||||||
|                 // Do not show author for first post and type single.
 |  | ||||||
|                 return; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             try { |  | ||||||
|                 const user = await CoreUser.getProfile(discussion.userid, this.courseId, true); |  | ||||||
| 
 |  | ||||||
|                 discussion.userfullname = user.fullname; |  | ||||||
|                 discussion.userpictureurl = user.profileimageurl; |  | ||||||
|             } catch (error) { |  | ||||||
|                 // Ignore errors.
 |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         await Promise.all(promises); |  | ||||||
| 
 |  | ||||||
|         // Sort discussion by time (newer first).
 |  | ||||||
|         offlineDiscussions.sort((a, b) => b.timecreated - a.timecreated); |  | ||||||
| 
 |  | ||||||
|         this.discussions.setOfflineDiscussions(offlineDiscussions); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Convenience function to get forum discussions. |  | ||||||
|      * |  | ||||||
|      * @param refresh Whether we're refreshing data. |  | ||||||
|      * @return Promise resolved when done. |  | ||||||
|      */ |  | ||||||
|     protected async fetchDiscussions(refresh: boolean): Promise<void> { |  | ||||||
|         const forum = this.forum!; |  | ||||||
|         this.discussions.fetchFailed = false; |  | ||||||
| 
 |  | ||||||
|         if (refresh) { |  | ||||||
|             this.page = 0; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         const response = await AddonModForum.getDiscussions(forum.id, { |  | ||||||
|             cmId: forum.cmid, |  | ||||||
|             sortOrder: this.selectedSortOrder!.value, |  | ||||||
|             page: this.page, |  | ||||||
|         }); |  | ||||||
|         let discussions = response.discussions; |  | ||||||
| 
 |  | ||||||
|         if (this.usesGroups) { |  | ||||||
|             discussions = await AddonModForum.formatDiscussionsGroups(forum.cmid, discussions); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // Hide author for first post and type single.
 |  | ||||||
|         if (forum.type === 'single') { |  | ||||||
|             for (const discussion of discussions) { |  | ||||||
|                 if (discussion.userfullname && discussion.parent === 0) { |  | ||||||
|                     discussion.userfullname = false; |  | ||||||
|                     break; |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // If any discussion has unread posts, the whole forum is being tracked.
 |  | ||||||
|         if (typeof forum.istracked === 'undefined' && !this.trackPosts) { |  | ||||||
|             for (const discussion of discussions) { |  | ||||||
|                 if (discussion.numunread > 0) { |  | ||||||
|                     this.trackPosts = true; |  | ||||||
|                     break; |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if (this.page === 0) { |  | ||||||
|             this.discussions.setOnlineDiscussions(discussions, response.canLoadMore); |  | ||||||
|         } else { |  | ||||||
|             this.discussions.setItems(this.discussions.items.concat(discussions), response.canLoadMore); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         this.page++; |  | ||||||
| 
 |  | ||||||
|         // Check if there are replies for discussions stored in offline.
 |  | ||||||
|         const hasOffline = await AddonModForumOffline.hasForumReplies(forum.id); |  | ||||||
| 
 |  | ||||||
|         this.hasOffline = this.hasOffline || hasOffline; |  | ||||||
| 
 |  | ||||||
|         if (hasOffline) { |  | ||||||
|             // Only update new fetched discussions.
 |  | ||||||
|             const promises = discussions.map(async (discussion) => { |  | ||||||
|                 // Get offline discussions.
 |  | ||||||
|                 const replies = await AddonModForumOffline.getDiscussionReplies(discussion.discussion); |  | ||||||
| 
 |  | ||||||
|                 discussion.numreplies = Number(discussion.numreplies) + replies.length; |  | ||||||
|             }); |  | ||||||
| 
 |  | ||||||
|             await Promise.all(promises); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |     /** | ||||||
|      * Convenience function to load more forum discussions. |      * Convenience function to load more forum discussions. | ||||||
|      * |      * | ||||||
| @ -492,11 +432,13 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|      */ |      */ | ||||||
|     async fetchMoreDiscussions(complete: () => void): Promise<void> { |     async fetchMoreDiscussions(complete: () => void): Promise<void> { | ||||||
|         try { |         try { | ||||||
|             await this.fetchDiscussions(false); |             this.fetchFailed = false; | ||||||
|  | 
 | ||||||
|  |             await this.discussions.load(); | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true); |             CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true); | ||||||
| 
 | 
 | ||||||
|             this.discussions.fetchFailed = true; |             this.fetchFailed = true; | ||||||
|         } finally { |         } finally { | ||||||
|             complete(); |             complete(); | ||||||
|         } |         } | ||||||
| @ -521,9 +463,13 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         const value = await getSortOrder(); |         const value = await getSortOrder(); | ||||||
|  |         const selectedOrder = this.sortOrders.find(sortOrder => sortOrder.value === value) || this.sortOrders[0]; | ||||||
| 
 | 
 | ||||||
|         this.selectedSortOrder = this.sortOrders.find(sortOrder => sortOrder.value === value) || this.sortOrders[0]; |         this.discussions.getSource().selectedSortOrder = selectedOrder; | ||||||
|         this.sortOrderSelectorModalOptions.componentProps!.selected = this.selectedSortOrder.value; | 
 | ||||||
|  |         if (this.sortOrderSelectorModalOptions.componentProps) { | ||||||
|  |             this.sortOrderSelectorModalOptions.componentProps.selected = selectedOrder.value; | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -597,11 +543,11 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|                 if (isNewDiscussion && CoreScreen.isTablet) { |                 if (isNewDiscussion && CoreScreen.isTablet) { | ||||||
|                     const newDiscussionData = data as AddonModForumNewDiscussionData; |                     const newDiscussionData = data as AddonModForumNewDiscussionData; | ||||||
|                     const discussion = this.discussions.items.find(disc => { |                     const discussion = this.discussions.items.find(disc => { | ||||||
|                         if (this.discussions.isOfflineDiscussion(disc)) { |                         if (this.discussions.getSource().isOfflineDiscussion(disc)) { | ||||||
|                             return disc.timecreated === newDiscussionData.discTimecreated; |                             return disc.timecreated === newDiscussionData.discTimecreated; | ||||||
|                         } |                         } | ||||||
| 
 | 
 | ||||||
|                         if (this.discussions.isOnlineDiscussion(disc)) { |                         if (this.discussions.getSource().isOnlineDiscussion(disc)) { | ||||||
|                             return CoreArray.contains(newDiscussionData.discussionIds ?? [], disc.discussion); |                             return CoreArray.contains(newDiscussionData.discussionIds ?? [], disc.discussion); | ||||||
|                         } |                         } | ||||||
| 
 | 
 | ||||||
| @ -625,7 +571,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|      * @param timeCreated Creation time of the offline discussion. |      * @param timeCreated Creation time of the offline discussion. | ||||||
|      */ |      */ | ||||||
|     openNewDiscussion(): void { |     openNewDiscussion(): void { | ||||||
|         this.discussions.select({ newDiscussion: true }); |         this.discussions.select(AddonModForumDiscussionsSource.NEW_DISCUSSION); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -634,10 +580,13 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|      * @param sortOrder Sort order new data. |      * @param sortOrder Sort order new data. | ||||||
|      */ |      */ | ||||||
|     async setSortOrder(sortOrder: AddonModForumSortOrder): Promise<void> { |     async setSortOrder(sortOrder: AddonModForumSortOrder): Promise<void> { | ||||||
|         if (sortOrder.value != this.selectedSortOrder?.value) { |         if (sortOrder.value != this.discussions.getSource().selectedSortOrder?.value) { | ||||||
|             this.selectedSortOrder = sortOrder; |             this.discussions.getSource().selectedSortOrder = sortOrder; | ||||||
|             this.sortOrderSelectorModalOptions.componentProps!.selected = this.selectedSortOrder.value; |             this.discussions.getSource().setDirty(true); | ||||||
|             this.page = 0; | 
 | ||||||
|  |             if (this.sortOrderSelectorModalOptions.componentProps) { | ||||||
|  |                 this.sortOrderSelectorModalOptions.componentProps.selected = sortOrder.value; | ||||||
|  |             } | ||||||
| 
 | 
 | ||||||
|             try { |             try { | ||||||
|                 await CoreUser.setUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER, sortOrder.value.toFixed(0)); |                 await CoreUser.setUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER, sortOrder.value.toFixed(0)); | ||||||
| @ -666,6 +615,10 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|      * @param discussion Discussion. |      * @param discussion Discussion. | ||||||
|      */ |      */ | ||||||
|     async showOptionsMenu(event: Event, discussion: AddonModForumDiscussion): Promise<void> { |     async showOptionsMenu(event: Event, discussion: AddonModForumDiscussion): Promise<void> { | ||||||
|  |         if (!this.forum) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|         event.preventDefault(); |         event.preventDefault(); | ||||||
|         event.stopPropagation(); |         event.stopPropagation(); | ||||||
| 
 | 
 | ||||||
| @ -673,7 +626,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
|             component: AddonModForumDiscussionOptionsMenuComponent, |             component: AddonModForumDiscussionOptionsMenuComponent, | ||||||
|             componentProps: { |             componentProps: { | ||||||
|                 discussion, |                 discussion, | ||||||
|                 forumId: this.forum!.id, |                 forumId: this.forum.id, | ||||||
|                 cmId: this.module.id, |                 cmId: this.module.id, | ||||||
|             }, |             }, | ||||||
|             event, |             event, | ||||||
| @ -698,125 +651,47 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | |||||||
| 
 | 
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** |  | ||||||
|  * Type to select the new discussion form. |  | ||||||
|  */ |  | ||||||
| type NewDiscussionForm = { newDiscussion: true }; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Type of items that can be held by the discussions manager. |  | ||||||
|  */ |  | ||||||
| type DiscussionItem = AddonModForumDiscussion | AddonModForumOfflineDiscussion | NewDiscussionForm; |  | ||||||
| 
 |  | ||||||
| /** | /** | ||||||
|  * Discussions manager. |  * Discussions manager. | ||||||
|  */ |  */ | ||||||
| class AddonModForumDiscussionsManager extends CorePageItemsListManager<DiscussionItem> { | class AddonModForumDiscussionsManager extends CoreListItemsManager<AddonModForumDiscussionItem, AddonModForumDiscussionsSource> { | ||||||
| 
 | 
 | ||||||
|     onlineLoaded = false; |     page: AddonModForumIndexComponent; | ||||||
|     fetchFailed = false; |  | ||||||
| 
 | 
 | ||||||
|     private discussionsPathPrefix: string; |     constructor(source: AddonModForumDiscussionsSource, page: AddonModForumIndexComponent) { | ||||||
|     private component: AddonModForumIndexComponent; |         super(source, page.route.component); | ||||||
| 
 | 
 | ||||||
|     constructor(pageComponent: unknown, component: AddonModForumIndexComponent, discussionsPathPrefix: string) { |         this.page = page; | ||||||
|         super(pageComponent); |  | ||||||
| 
 |  | ||||||
|         this.component = component; |  | ||||||
|         this.discussionsPathPrefix = discussionsPathPrefix; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     get loaded(): boolean { |  | ||||||
|         return super.loaded && (this.onlineLoaded || this.fetchFailed); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     get onlineDiscussions(): AddonModForumDiscussion[] { |  | ||||||
|         return this.items.filter(discussion => this.isOnlineDiscussion(discussion)) as AddonModForumDiscussion[]; |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     getItemQueryParams(discussion: DiscussionItem): Params { |     protected getDefaultItem(): AddonModForumDiscussionItem | null { | ||||||
|         return { |         const source = this.getSource(); | ||||||
|             courseId: this.component.courseId, |  | ||||||
|             cmId: this.component.module.id, |  | ||||||
|             forumId: this.component.forum!.id, |  | ||||||
|             ...(this.isOnlineDiscussion(discussion) ? { discussion, trackPosts: this.component.trackPosts } : {}), |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     /** |         return this.items.find(discussion => !source.isNewDiscussionForm(discussion)) || null; | ||||||
|      * Type guard to infer NewDiscussionForm objects. |  | ||||||
|      * |  | ||||||
|      * @param discussion Item to check. |  | ||||||
|      * @return Whether the item is a new discussion form. |  | ||||||
|      */ |  | ||||||
|     isNewDiscussionForm(discussion: DiscussionItem): discussion is NewDiscussionForm { |  | ||||||
|         return 'newDiscussion' in discussion; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Type guard to infer AddonModForumDiscussion objects. |  | ||||||
|      * |  | ||||||
|      * @param discussion Item to check. |  | ||||||
|      * @return Whether the item is an online discussion. |  | ||||||
|      */ |  | ||||||
|     isOfflineDiscussion(discussion: DiscussionItem): discussion is AddonModForumOfflineDiscussion { |  | ||||||
|         return !this.isNewDiscussionForm(discussion) |  | ||||||
|             && !this.isOnlineDiscussion(discussion); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Type guard to infer AddonModForumDiscussion objects. |  | ||||||
|      * |  | ||||||
|      * @param discussion Item to check. |  | ||||||
|      * @return Whether the item is an online discussion. |  | ||||||
|      */ |  | ||||||
|     isOnlineDiscussion(discussion: DiscussionItem): discussion is AddonModForumDiscussion { |  | ||||||
|         return 'id' in discussion; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Update online discussion items. |  | ||||||
|      * |  | ||||||
|      * @param onlineDiscussions Online discussions |  | ||||||
|      */ |  | ||||||
|     setOnlineDiscussions(onlineDiscussions: AddonModForumDiscussion[], hasMoreItems: boolean = false): void { |  | ||||||
|         const otherDiscussions = this.items.filter(discussion => !this.isOnlineDiscussion(discussion)); |  | ||||||
| 
 |  | ||||||
|         this.setItems(otherDiscussions.concat(onlineDiscussions), hasMoreItems); |  | ||||||
|         this.onlineLoaded = true; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Update offline discussion items. |  | ||||||
|      * |  | ||||||
|      * @param offlineDiscussions Offline discussions |  | ||||||
|      */ |  | ||||||
|     setOfflineDiscussions(offlineDiscussions: AddonModForumOfflineDiscussion[]): void { |  | ||||||
|         const otherDiscussions = this.items.filter(discussion => !this.isOfflineDiscussion(discussion)); |  | ||||||
| 
 |  | ||||||
|         this.setItems((offlineDiscussions as DiscussionItem[]).concat(otherDiscussions), this.hasMoreItems); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     protected getItemPath(discussion: DiscussionItem): string { |     protected async logActivity(): Promise<void> { | ||||||
|         const getRelativePath = () => { |         const forum = this.getSource().forum; | ||||||
|             if (this.isOnlineDiscussion(discussion)) { |  | ||||||
|                 return discussion.discussion; |  | ||||||
|             } |  | ||||||
| 
 | 
 | ||||||
|             if (this.isOfflineDiscussion(discussion)) { |         if (!forum) { | ||||||
|                 return `new/${discussion.timecreated}`; |             return; | ||||||
|             } |         } | ||||||
| 
 | 
 | ||||||
|             return 'new/0'; |         CoreUtils.ignoreErrors( | ||||||
|         }; |             AddonModForum.instance | ||||||
|  |                 .logView(forum.id, forum.name) | ||||||
|  |                 .then(async () => { | ||||||
|  |                     CoreCourse.checkModuleCompletion(this.page.courseId, this.page.module.completiondata); | ||||||
| 
 | 
 | ||||||
|         return this.discussionsPathPrefix + getRelativePath(); |                     return; | ||||||
|  |                 }), | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| } | } | ||||||
|  | |||||||
| @ -55,6 +55,7 @@ const mainMenuRoutes: Routes = [ | |||||||
|     { |     { | ||||||
|         path: `${AddonModForumModuleHandlerService.PAGE_NAME}/discussion/:discussionId`, |         path: `${AddonModForumModuleHandlerService.PAGE_NAME}/discussion/:discussionId`, | ||||||
|         loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule), |         loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule), | ||||||
|  |         data: { swipeEnabled: false }, | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|         path: AddonModForumModuleHandlerService.PAGE_NAME, |         path: AddonModForumModuleHandlerService.PAGE_NAME, | ||||||
| @ -66,10 +67,12 @@ const mainMenuRoutes: Routes = [ | |||||||
|                 path: `${COURSE_CONTENTS_PATH}/${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`, |                 path: `${COURSE_CONTENTS_PATH}/${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`, | ||||||
|                 loadChildren: () => import('./pages/new-discussion/new-discussion.module') |                 loadChildren: () => import('./pages/new-discussion/new-discussion.module') | ||||||
|                     .then(m => m.AddonForumNewDiscussionPageModule), |                     .then(m => m.AddonForumNewDiscussionPageModule), | ||||||
|  |                 data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` }, | ||||||
|             }, |             }, | ||||||
|             { |             { | ||||||
|                 path: `${COURSE_CONTENTS_PATH}/${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`, |                 path: `${COURSE_CONTENTS_PATH}/${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`, | ||||||
|                 loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule), |                 loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule), | ||||||
|  |                 data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` }, | ||||||
|             }, |             }, | ||||||
|         ], |         ], | ||||||
|         () => CoreScreen.isMobile, |         () => CoreScreen.isMobile, | ||||||
| @ -82,10 +85,12 @@ const courseContentsRoutes: Routes = conditionalRoutes( | |||||||
|             path: `${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`, |             path: `${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`, | ||||||
|             loadChildren: () => import('./pages/new-discussion/new-discussion.module') |             loadChildren: () => import('./pages/new-discussion/new-discussion.module') | ||||||
|                 .then(m => m.AddonForumNewDiscussionPageModule), |                 .then(m => m.AddonForumNewDiscussionPageModule), | ||||||
|  |             data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` }, | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|             path: `${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`, |             path: `${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`, | ||||||
|             loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule), |             loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule), | ||||||
|  |             data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` }, | ||||||
|         }, |         }, | ||||||
|     ], |     ], | ||||||
|     () => CoreScreen.isTablet, |     () => CoreScreen.isTablet, | ||||||
|  | |||||||
| @ -56,72 +56,74 @@ | |||||||
|     </core-context-menu> |     </core-context-menu> | ||||||
| </core-navbar-buttons> | </core-navbar-buttons> | ||||||
| <ion-content> | <ion-content> | ||||||
|     <ion-refresher slot="fixed" [disabled]="!discussionLoaded" (ionRefresh)="doRefresh($event.target)"> |     <core-swipe-navigation [manager]="discussions"> | ||||||
|         <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> |         <ion-refresher slot="fixed" [disabled]="!discussionLoaded" (ionRefresh)="doRefresh($event.target)"> | ||||||
|     </ion-refresher> |             <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||||
|  |         </ion-refresher> | ||||||
| 
 | 
 | ||||||
|     <core-loading [hideUntil]="discussionLoaded"> |         <core-loading [hideUntil]="discussionLoaded"> | ||||||
|         <!-- Discussion replies found to be synchronized --> |             <!-- Discussion replies found to be synchronized --> | ||||||
|         <ion-card class="core-warning-card" *ngIf="postHasOffline || hasOfflineRatings"> |             <ion-card class="core-warning-card" *ngIf="postHasOffline || hasOfflineRatings"> | ||||||
|             <ion-item> |                 <ion-item> | ||||||
|                 <ion-icon name="fas-exclamation-triangle" slot="start" aria-hidden="true"></ion-icon> |                     <ion-icon name="fas-exclamation-triangle" slot="start" aria-hidden="true"></ion-icon> | ||||||
|                 <ion-label>{{ 'core.hasdatatosync' | translate:{$a: discussionStr} }}</ion-label> |                     <ion-label>{{ 'core.hasdatatosync' | translate:{$a: discussionStr} }}</ion-label> | ||||||
|             </ion-item> |                 </ion-item> | ||||||
|         </ion-card> |  | ||||||
| 
 |  | ||||||
|         <!-- Cut-off date or due date message --> |  | ||||||
|         <ion-card class="core-info-card" *ngIf="availabilityMessage"> |  | ||||||
|             <ion-item> |  | ||||||
|                 <ion-icon name="fas-info-circle" slot="start" aria-hidden="true"></ion-icon> |  | ||||||
|                 <ion-label>{{ availabilityMessage }}</ion-label> |  | ||||||
|             </ion-item> |  | ||||||
|         </ion-card> |  | ||||||
| 
 |  | ||||||
|         <ion-card class="core-info-card" *ngIf="discussion && discussion.locked"> |  | ||||||
|             <ion-item> |  | ||||||
|                 <ion-icon name="fas-lock" slot="start" aria-hidden="true"></ion-icon> |  | ||||||
|                 <ion-label>{{ 'addon.mod_forum.discussionlocked' | translate }}</ion-label> |  | ||||||
|             </ion-item> |  | ||||||
|         </ion-card> |  | ||||||
| 
 |  | ||||||
|         <div *ngIf="startingPost" class="ion-margin-bottom"> |  | ||||||
|             <addon-mod-forum-post [post]="startingPost" [discussion]="discussion" [courseId]="courseId" [highlight]="true" |  | ||||||
|                 [discussionId]="discussionId" [component]="component" [componentId]="cmId" [formData]="formData" |  | ||||||
|                 [originalData]="originalData" [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" |  | ||||||
|                 [leavingPage]="leavingPage" (onPostChange)="postListChanged()"> |  | ||||||
|             </addon-mod-forum-post> |  | ||||||
|         </div> |  | ||||||
| 
 |  | ||||||
|         <ion-card *ngIf="sort != 'nested'"> |  | ||||||
|             <ng-container *ngFor="let post of posts; first as first"> |  | ||||||
|                 <core-spacer *ngIf="!first"></core-spacer> |  | ||||||
|                 <addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" |  | ||||||
|                     [componentId]="cmId" [formData]="formData" [originalData]="originalData" [parentSubject]="postSubjects[post.parentid]" |  | ||||||
|                     [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" |  | ||||||
|                     [leavingPage]="leavingPage" (onPostChange)="postListChanged()"> |  | ||||||
|                 </addon-mod-forum-post> |  | ||||||
|             </ng-container> |  | ||||||
|         </ion-card> |  | ||||||
| 
 |  | ||||||
|         <ng-container *ngIf="sort == 'nested'"> |  | ||||||
|             <ng-container *ngFor="let post of posts"> |  | ||||||
|                 <ng-container *ngTemplateOutlet="nestedPosts; context: {post: post}"></ng-container> |  | ||||||
|             </ng-container> |  | ||||||
|         </ng-container> |  | ||||||
| 
 |  | ||||||
|         <ng-template #nestedPosts let-post="post"> |  | ||||||
|             <ion-card> |  | ||||||
|                 <addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" |  | ||||||
|                     [componentId]="cmId" [formData]="formData" [originalData]="originalData" [parentSubject]="postSubjects[post.parentid]" |  | ||||||
|                     [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" |  | ||||||
|                     [leavingPage]="leavingPage" (onPostChange)="postListChanged()"> |  | ||||||
|                 </addon-mod-forum-post> |  | ||||||
|             </ion-card> |             </ion-card> | ||||||
|             <div class="ion-padding-start" *ngIf="post.children && post.children.length && post.children[0].subject"> | 
 | ||||||
|                 <ng-container *ngFor="let child of post.children"> |             <!-- Cut-off date or due date message --> | ||||||
|                     <ng-container *ngTemplateOutlet="nestedPosts; context: {post: child}"></ng-container> |             <ion-card class="core-info-card" *ngIf="availabilityMessage"> | ||||||
|                 </ng-container> |                 <ion-item> | ||||||
|  |                     <ion-icon name="fas-info-circle" slot="start" aria-hidden="true"></ion-icon> | ||||||
|  |                     <ion-label>{{ availabilityMessage }}</ion-label> | ||||||
|  |                 </ion-item> | ||||||
|  |             </ion-card> | ||||||
|  | 
 | ||||||
|  |             <ion-card class="core-info-card" *ngIf="discussion && discussion.locked"> | ||||||
|  |                 <ion-item> | ||||||
|  |                     <ion-icon name="fas-lock" slot="start" aria-hidden="true"></ion-icon> | ||||||
|  |                     <ion-label>{{ 'addon.mod_forum.discussionlocked' | translate }}</ion-label> | ||||||
|  |                 </ion-item> | ||||||
|  |             </ion-card> | ||||||
|  | 
 | ||||||
|  |             <div *ngIf="startingPost" class="ion-margin-bottom"> | ||||||
|  |                 <addon-mod-forum-post [post]="startingPost" [discussion]="discussion" [courseId]="courseId" [highlight]="true" | ||||||
|  |                     [discussionId]="discussionId" [component]="component" [componentId]="cmId" [formData]="formData" | ||||||
|  |                     [originalData]="originalData" [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" | ||||||
|  |                     [ratingInfo]="ratingInfo" [leavingPage]="leavingPage" (onPostChange)="postListChanged()"> | ||||||
|  |                 </addon-mod-forum-post> | ||||||
|             </div> |             </div> | ||||||
|         </ng-template> | 
 | ||||||
|     </core-loading> |             <ion-card *ngIf="sort != 'nested'"> | ||||||
|  |                 <ng-container *ngFor="let post of posts; first as first"> | ||||||
|  |                     <core-spacer *ngIf="!first"></core-spacer> | ||||||
|  |                     <addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" | ||||||
|  |                         [componentId]="cmId" [formData]="formData" [originalData]="originalData" | ||||||
|  |                         [parentSubject]="postSubjects[post.parentid]" [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" | ||||||
|  |                         [ratingInfo]="ratingInfo" [leavingPage]="leavingPage" (onPostChange)="postListChanged()"> | ||||||
|  |                     </addon-mod-forum-post> | ||||||
|  |                 </ng-container> | ||||||
|  |             </ion-card> | ||||||
|  | 
 | ||||||
|  |             <ng-container *ngIf="sort == 'nested'"> | ||||||
|  |                 <ng-container *ngFor="let post of posts"> | ||||||
|  |                     <ng-container *ngTemplateOutlet="nestedPosts; context: {post: post}"></ng-container> | ||||||
|  |                 </ng-container> | ||||||
|  |             </ng-container> | ||||||
|  | 
 | ||||||
|  |             <ng-template #nestedPosts let-post="post"> | ||||||
|  |                 <ion-card> | ||||||
|  |                     <addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" | ||||||
|  |                         [componentId]="cmId" [formData]="formData" [originalData]="originalData" | ||||||
|  |                         [parentSubject]="postSubjects[post.parentid]" [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" | ||||||
|  |                         [ratingInfo]="ratingInfo" [leavingPage]="leavingPage" (onPostChange)="postListChanged()"> | ||||||
|  |                     </addon-mod-forum-post> | ||||||
|  |                 </ion-card> | ||||||
|  |                 <div class="ion-padding-start" *ngIf="post.children && post.children.length && post.children[0].subject"> | ||||||
|  |                     <ng-container *ngFor="let child of post.children"> | ||||||
|  |                         <ng-container *ngTemplateOutlet="nestedPosts; context: {post: child}"></ng-container> | ||||||
|  |                     </ng-container> | ||||||
|  |                 </div> | ||||||
|  |             </ng-template> | ||||||
|  |         </core-loading> | ||||||
|  |     </core-swipe-navigation> | ||||||
| </ion-content> | </ion-content> | ||||||
|  | |||||||
| @ -14,6 +14,8 @@ | |||||||
| 
 | 
 | ||||||
| import { ContextLevel, CoreConstants } from '@/core/constants'; | import { ContextLevel, CoreConstants } from '@/core/constants'; | ||||||
| import { Component, OnDestroy, ViewChild, OnInit, AfterViewInit, ElementRef, Optional } from '@angular/core'; | import { Component, OnDestroy, ViewChild, OnInit, AfterViewInit, ElementRef, Optional } from '@angular/core'; | ||||||
|  | import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||||
|  | import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||||
| import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; | import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; | ||||||
| import { CoreRatingInfo, CoreRatingProvider } from '@features/rating/services/rating'; | import { CoreRatingInfo, CoreRatingProvider } from '@features/rating/services/rating'; | ||||||
| @ -32,6 +34,8 @@ import { Network, NgZone, Translate } from '@singletons'; | |||||||
| import { CoreArray } from '@singletons/array'; | import { CoreArray } from '@singletons/array'; | ||||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||||
| import { Subscription } from 'rxjs'; | import { Subscription } from 'rxjs'; | ||||||
|  | import { AddonModForumDiscussionsSource } from '../../classes/forum-discussions-source'; | ||||||
|  | import { AddonModForumDiscussionsSwipeManager } from '../../classes/forum-discussions-swipe-manager'; | ||||||
| import { | import { | ||||||
|     AddonModForum, |     AddonModForum, | ||||||
|     AddonModForumAccessInformation, |     AddonModForumAccessInformation, | ||||||
| @ -68,6 +72,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes | |||||||
|     forum: Partial<AddonModForumData> = {}; |     forum: Partial<AddonModForumData> = {}; | ||||||
|     accessInfo: AddonModForumAccessInformation = {}; |     accessInfo: AddonModForumAccessInformation = {}; | ||||||
|     discussion?: AddonModForumDiscussion; |     discussion?: AddonModForumDiscussion; | ||||||
|  |     discussions?: AddonModForumDiscussionDiscussionsSwipeManager; | ||||||
|     startingPost?: Post; |     startingPost?: Post; | ||||||
|     posts!: Post[]; |     posts!: Post[]; | ||||||
|     discussionLoaded = false; |     discussionLoaded = false; | ||||||
| @ -117,14 +122,16 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes | |||||||
|     constructor( |     constructor( | ||||||
|         @Optional() protected splitView: CoreSplitViewComponent, |         @Optional() protected splitView: CoreSplitViewComponent, | ||||||
|         protected elementRef: ElementRef, |         protected elementRef: ElementRef, | ||||||
|  |         protected route: ActivatedRoute, | ||||||
|     ) {} |     ) {} | ||||||
| 
 | 
 | ||||||
|     get isMobile(): boolean { |     get isMobile(): boolean { | ||||||
|         return CoreScreen.isMobile; |         return CoreScreen.isMobile; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     ngOnInit(): void { |     async ngOnInit(): Promise<void> { | ||||||
|         try { |         try { | ||||||
|  |             const routeData = this.route.snapshot.data; | ||||||
|             this.courseId = CoreNavigator.getRouteNumberParam('courseId'); |             this.courseId = CoreNavigator.getRouteNumberParam('courseId'); | ||||||
|             this.cmId = CoreNavigator.getRouteNumberParam('cmId'); |             this.cmId = CoreNavigator.getRouteNumberParam('cmId'); | ||||||
|             this.forumId = CoreNavigator.getRouteNumberParam('forumId'); |             this.forumId = CoreNavigator.getRouteNumberParam('forumId'); | ||||||
| @ -136,6 +143,16 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes | |||||||
|             this.postId = CoreNavigator.getRouteNumberParam('postId'); |             this.postId = CoreNavigator.getRouteNumberParam('postId'); | ||||||
|             this.parent = CoreNavigator.getRouteNumberParam('parent'); |             this.parent = CoreNavigator.getRouteNumberParam('parent'); | ||||||
| 
 | 
 | ||||||
|  |             if (this.courseId && this.cmId && (routeData.swipeEnabled ?? true)) { | ||||||
|  |                 this.discussions = new AddonModForumDiscussionDiscussionsSwipeManager( | ||||||
|  |                     CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||||
|  |                         AddonModForumDiscussionsSource, | ||||||
|  |                         [this.courseId, this.cmId, routeData.discussionsPathPrefix ?? ''], | ||||||
|  |                     ), | ||||||
|  |                 ); | ||||||
|  | 
 | ||||||
|  |                 await this.discussions.start(); | ||||||
|  |             } | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             CoreDomUtils.showErrorModal(error); |             CoreDomUtils.showErrorModal(error); | ||||||
| 
 | 
 | ||||||
| @ -311,6 +328,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes | |||||||
|      */ |      */ | ||||||
|     ngOnDestroy(): void { |     ngOnDestroy(): void { | ||||||
|         this.onlineObserver && this.onlineObserver.unsubscribe(); |         this.onlineObserver && this.onlineObserver.unsubscribe(); | ||||||
|  |         this.discussions && this.discussions.destroy(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -839,3 +857,17 @@ export type AddonModForumSharedPostFormData = Omit<AddonModForumPostFormData, 'i | |||||||
|     id?: number; // ID when editing an online reply.
 |     id?: number; // ID when editing an online reply.
 | ||||||
|     syncId?: string; // Sync ID if some post has blocked synchronization.
 |     syncId?: string; // Sync ID if some post has blocked synchronization.
 | ||||||
| }; | }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Helper to manage swiping within a collection of discussions. | ||||||
|  |  */ | ||||||
|  | class AddonModForumDiscussionDiscussionsSwipeManager extends AddonModForumDiscussionsSwipeManager { | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||||
|  |         return this.getSource().DISCUSSIONS_PATH_PREFIX + route.params.discussionId; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | |||||||
| @ -12,76 +12,77 @@ | |||||||
|     </ion-toolbar> |     </ion-toolbar> | ||||||
| </ion-header> | </ion-header> | ||||||
| <ion-content> | <ion-content> | ||||||
|     <ion-refresher slot="fixed" [disabled]="!groupsLoaded" (ionRefresh)="refreshGroups($event.target)"> |     <core-swipe-navigation [manager]="discussions"> | ||||||
|         <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> |         <ion-refresher slot="fixed" [disabled]="!groupsLoaded" (ionRefresh)="refreshGroups($event.target)"> | ||||||
|     </ion-refresher> |             <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||||
| 
 |         </ion-refresher> | ||||||
|     <core-loading [hideUntil]="groupsLoaded"> |         <core-loading [hideUntil]="groupsLoaded"> | ||||||
|         <form *ngIf="showForm" #newDiscFormEl> |             <form *ngIf="showForm" #newDiscFormEl> | ||||||
|             <ion-item> |                 <ion-item> | ||||||
|                 <ion-label position="stacked">{{ 'addon.mod_forum.subject' | translate }}</ion-label> |                     <ion-label position="stacked">{{ 'addon.mod_forum.subject' | translate }}</ion-label> | ||||||
|                 <ion-input [(ngModel)]="newDiscussion.subject" type="text" [placeholder]="'addon.mod_forum.subject' | translate" |                     <ion-input [(ngModel)]="newDiscussion.subject" type="text" [placeholder]="'addon.mod_forum.subject' | translate" | ||||||
|                     name="subject"> |                         name="subject"> | ||||||
|                 </ion-input> |                     </ion-input> | ||||||
|             </ion-item> |  | ||||||
|             <ion-item> |  | ||||||
|                 <ion-label position="stacked">{{ 'addon.mod_forum.message' | translate }}</ion-label> |  | ||||||
|                 <core-rich-text-editor name="addon_mod_forum_new_discussion" contextLevel="module" elementId="message" |  | ||||||
|                     [control]="messageControl" [placeholder]="'addon.mod_forum.message' | translate" [component]="component" |  | ||||||
|                     [componentId]="forum.cmid" [autoSave]="true" [contextInstanceId]="forum.cmid" |  | ||||||
|                     (contentChanged)="onMessageChange($event)"> |  | ||||||
|                 </core-rich-text-editor> |  | ||||||
|             </ion-item> |  | ||||||
|             <ion-item button class="divider ion-text-wrap" (click)="toggleAdvanced()" detail="false" [attr.aria-expanded]="advanced" |  | ||||||
|                 [attr.aria-label]="(advanced ? 'core.hideadvanced' : 'core.showadvanced') | translate" role="heading" |  | ||||||
|                 aria-controls="addon-mod-forum-new-discussion-advanced"> |  | ||||||
|                 <ion-icon *ngIf="!advanced" name="fas-caret-right" flip-rtl slot="start" aria-hidden="true"></ion-icon> |  | ||||||
|                 <ion-icon *ngIf="advanced" name="fas-caret-down" slot="start" aria-hidden="true"></ion-icon> |  | ||||||
|                 <ion-label> |  | ||||||
|                     <h2>{{ 'addon.mod_forum.advanced' | translate }}</h2> |  | ||||||
|                 </ion-label> |  | ||||||
|             </ion-item> |  | ||||||
|             <div *ngIf="advanced" id="addon-mod-forum-new-discussion-advanced"> |  | ||||||
|                 <ion-item *ngIf="showGroups && groupIds.length > 1 && accessInfo.cancanposttomygroups"> |  | ||||||
|                     <ion-label>{{ 'addon.mod_forum.posttomygroups' | translate }}</ion-label> |  | ||||||
|                     <ion-toggle [(ngModel)]="newDiscussion.postToAllGroups" name="postallgroups"></ion-toggle> |  | ||||||
|                 </ion-item> |  | ||||||
|                 <ion-item *ngIf="showGroups"> |  | ||||||
|                     <ion-label id="addon-mod-forum-groupslabel">{{ 'addon.mod_forum.group' | translate }}</ion-label> |  | ||||||
|                     <ion-select [(ngModel)]="newDiscussion.groupId" [disabled]="newDiscussion.postToAllGroups" |  | ||||||
|                         aria-labelledby="addon-mod-forum-groupslabel" interface="action-sheet" name="groupid" |  | ||||||
|                         [interfaceOptions]="{header: 'addon.mod_forum.group' | translate}"> |  | ||||||
|                         <ion-select-option *ngFor="let group of groups" [value]="group.id">{{ group.name }}</ion-select-option> |  | ||||||
|                     </ion-select> |  | ||||||
|                 </ion-item> |                 </ion-item> | ||||||
|                 <ion-item> |                 <ion-item> | ||||||
|                     <ion-label>{{ 'addon.mod_forum.discussionsubscription' | translate }}</ion-label> |                     <ion-label position="stacked">{{ 'addon.mod_forum.message' | translate }}</ion-label> | ||||||
|                     <ion-toggle [(ngModel)]="newDiscussion.subscribe" name="subscribe"></ion-toggle> |                     <core-rich-text-editor name="addon_mod_forum_new_discussion" contextLevel="module" elementId="message" | ||||||
|  |                         [control]="messageControl" [placeholder]="'addon.mod_forum.message' | translate" [component]="component" | ||||||
|  |                         [componentId]="forum.cmid" [autoSave]="true" [contextInstanceId]="forum.cmid" | ||||||
|  |                         (contentChanged)="onMessageChange($event)"> | ||||||
|  |                     </core-rich-text-editor> | ||||||
|                 </ion-item> |                 </ion-item> | ||||||
|                 <ion-item *ngIf="canPin"> |                 <ion-item button class="divider ion-text-wrap" (click)="toggleAdvanced()" detail="false" [attr.aria-expanded]="advanced" | ||||||
|                     <ion-label>{{ 'addon.mod_forum.discussionpinned' | translate }}</ion-label> |                     [attr.aria-label]="(advanced ? 'core.hideadvanced' : 'core.showadvanced') | translate" role="heading" | ||||||
|                     <ion-toggle [(ngModel)]="newDiscussion.pin" name="pin"></ion-toggle> |                     aria-controls="addon-mod-forum-new-discussion-advanced"> | ||||||
|  |                     <ion-icon *ngIf="!advanced" name="fas-caret-right" flip-rtl slot="start" aria-hidden="true"></ion-icon> | ||||||
|  |                     <ion-icon *ngIf="advanced" name="fas-caret-down" slot="start" aria-hidden="true"></ion-icon> | ||||||
|  |                     <ion-label> | ||||||
|  |                         <h2>{{ 'addon.mod_forum.advanced' | translate }}</h2> | ||||||
|  |                     </ion-label> | ||||||
|                 </ion-item> |                 </ion-item> | ||||||
|                 <core-attachments *ngIf="canCreateAttachments && forum && forum.maxattachments > 0" [files]="newDiscussion.files" |                 <div *ngIf="advanced" id="addon-mod-forum-new-discussion-advanced"> | ||||||
|                     [maxSize]="forum.maxbytes" [maxSubmissions]="forum.maxattachments" [component]="component" [componentId]="forum.cmid" |                     <ion-item *ngIf="showGroups && groupIds.length > 1 && accessInfo.cancanposttomygroups"> | ||||||
|                     [allowOffline]="true" [courseId]="courseId"> |                         <ion-label>{{ 'addon.mod_forum.posttomygroups' | translate }}</ion-label> | ||||||
|                 </core-attachments> |                         <ion-toggle [(ngModel)]="newDiscussion.postToAllGroups" name="postallgroups"></ion-toggle> | ||||||
|             </div> |                     </ion-item> | ||||||
|             <ion-item> |                     <ion-item *ngIf="showGroups"> | ||||||
|                 <ion-label> |                         <ion-label id="addon-mod-forum-groupslabel">{{ 'addon.mod_forum.group' | translate }}</ion-label> | ||||||
|                     <ion-row> |                         <ion-select [(ngModel)]="newDiscussion.groupId" [disabled]="newDiscussion.postToAllGroups" | ||||||
|                         <ion-col> |                             aria-labelledby="addon-mod-forum-groupslabel" interface="action-sheet" name="groupid" | ||||||
|                             <ion-button expand="block" [disabled]="newDiscussion.subject == '' || newDiscussion.message == null" |                             [interfaceOptions]="{header: 'addon.mod_forum.group' | translate}"> | ||||||
|                                 (click)="add()"> |                             <ion-select-option *ngFor="let group of groups" [value]="group.id">{{ group.name }}</ion-select-option> | ||||||
|                                 {{ 'addon.mod_forum.posttoforum' | translate }} |                         </ion-select> | ||||||
|                             </ion-button> |                     </ion-item> | ||||||
|                         </ion-col> |                     <ion-item> | ||||||
|                         <ion-col *ngIf="hasOffline"> |                         <ion-label>{{ 'addon.mod_forum.discussionsubscription' | translate }}</ion-label> | ||||||
|                             <ion-button expand="block" color="light" (click)="discard()">{{ 'core.discard' | translate }}</ion-button> |                         <ion-toggle [(ngModel)]="newDiscussion.subscribe" name="subscribe"></ion-toggle> | ||||||
|                         </ion-col> |                     </ion-item> | ||||||
|                     </ion-row> |                     <ion-item *ngIf="canPin"> | ||||||
|                 </ion-label> |                         <ion-label>{{ 'addon.mod_forum.discussionpinned' | translate }}</ion-label> | ||||||
|             </ion-item> |                         <ion-toggle [(ngModel)]="newDiscussion.pin" name="pin"></ion-toggle> | ||||||
|         </form> |                     </ion-item> | ||||||
|     </core-loading> |                     <core-attachments *ngIf="canCreateAttachments && forum && forum.maxattachments > 0" [files]="newDiscussion.files" | ||||||
|  |                         [maxSize]="forum.maxbytes" [maxSubmissions]="forum.maxattachments" [component]="component" | ||||||
|  |                         [componentId]="forum.cmid" [allowOffline]="true" [courseId]="courseId"> | ||||||
|  |                     </core-attachments> | ||||||
|  |                 </div> | ||||||
|  |                 <ion-item> | ||||||
|  |                     <ion-label> | ||||||
|  |                         <ion-row> | ||||||
|  |                             <ion-col> | ||||||
|  |                                 <ion-button expand="block" [disabled]="newDiscussion.subject == '' || newDiscussion.message == null" | ||||||
|  |                                     (click)="add()"> | ||||||
|  |                                     {{ 'addon.mod_forum.posttoforum' | translate }} | ||||||
|  |                                 </ion-button> | ||||||
|  |                             </ion-col> | ||||||
|  |                             <ion-col *ngIf="hasOffline"> | ||||||
|  |                                 <ion-button expand="block" color="light" (click)="discard()">{{ 'core.discard' | translate }}</ion-button> | ||||||
|  |                             </ion-col> | ||||||
|  |                         </ion-row> | ||||||
|  |                     </ion-label> | ||||||
|  |                 </ion-item> | ||||||
|  |             </form> | ||||||
|  |         </core-loading> | ||||||
|  |     </core-swipe-navigation> | ||||||
| </ion-content> | </ion-content> | ||||||
|  | |||||||
| @ -40,6 +40,10 @@ import { CoreTextUtils } from '@services/utils/text'; | |||||||
| import { CanLeave } from '@guards/can-leave'; | import { CanLeave } from '@guards/can-leave'; | ||||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||||
| import { CoreForms } from '@singletons/form'; | import { CoreForms } from '@singletons/form'; | ||||||
|  | import { AddonModForumDiscussionsSwipeManager } from '../../classes/forum-discussions-swipe-manager'; | ||||||
|  | import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||||
|  | import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||||
|  | import { AddonModForumDiscussionsSource } from '../../classes/forum-discussions-source'; | ||||||
| 
 | 
 | ||||||
| type NewDiscussionData = { | type NewDiscussionData = { | ||||||
|     subject: string; |     subject: string; | ||||||
| @ -88,6 +92,8 @@ export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLea | |||||||
|     accessInfo: AddonModForumAccessInformation = {}; |     accessInfo: AddonModForumAccessInformation = {}; | ||||||
|     courseId!: number; |     courseId!: number; | ||||||
| 
 | 
 | ||||||
|  |     discussions?: AddonModForumNewDiscussionDiscussionsSwipeManager; | ||||||
|  | 
 | ||||||
|     protected cmId!: number; |     protected cmId!: number; | ||||||
|     protected forumId!: number; |     protected forumId!: number; | ||||||
|     protected timeCreated!: number; |     protected timeCreated!: number; | ||||||
| @ -97,17 +103,29 @@ export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLea | |||||||
|     protected originalData?: Partial<NewDiscussionData>; |     protected originalData?: Partial<NewDiscussionData>; | ||||||
|     protected forceLeave = false; |     protected forceLeave = false; | ||||||
| 
 | 
 | ||||||
|     constructor(@Optional() protected splitView: CoreSplitViewComponent) {} |     constructor(protected route: ActivatedRoute, @Optional() protected splitView: CoreSplitViewComponent) {} | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Component being initialized. |      * Component being initialized. | ||||||
|      */ |      */ | ||||||
|     ngOnInit(): void { |     async ngOnInit(): Promise<void> { | ||||||
|         try { |         try { | ||||||
|  |             const routeData = this.route.snapshot.data; | ||||||
|             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); |             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||||
|             this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); |             this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); | ||||||
|             this.forumId = CoreNavigator.getRequiredRouteNumberParam('forumId'); |             this.forumId = CoreNavigator.getRequiredRouteNumberParam('forumId'); | ||||||
|             this.timeCreated = CoreNavigator.getRequiredRouteNumberParam('timeCreated'); |             this.timeCreated = CoreNavigator.getRequiredRouteNumberParam('timeCreated'); | ||||||
|  | 
 | ||||||
|  |             if (this.timeCreated !== 0 && (routeData.swipeEnabled ?? true)) { | ||||||
|  |                 const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||||
|  |                     AddonModForumDiscussionsSource, | ||||||
|  |                     [this.courseId, this.cmId, routeData.discussionsPathPrefix ?? ''], | ||||||
|  |                 ); | ||||||
|  | 
 | ||||||
|  |                 this.discussions = new AddonModForumNewDiscussionDiscussionsSwipeManager(source); | ||||||
|  | 
 | ||||||
|  |                 await this.discussions.start(); | ||||||
|  |             } | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             CoreDomUtils.showErrorModal(error); |             CoreDomUtils.showErrorModal(error); | ||||||
| 
 | 
 | ||||||
| @ -625,3 +643,17 @@ export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLea | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Helper to manage swiping within a collection of discussions. | ||||||
|  |  */ | ||||||
|  | class AddonModForumNewDiscussionDiscussionsSwipeManager extends AddonModForumDiscussionsSwipeManager { | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||||
|  |         return `${this.getSource().DISCUSSIONS_PATH_PREFIX}new/${route.params.timeCreated}`; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | |||||||
							
								
								
									
										381
									
								
								src/addons/mod/glossary/classes/glossary-entries-source.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										381
									
								
								src/addons/mod/glossary/classes/glossary-entries-source.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,381 @@ | |||||||
|  | // (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 { Params } from '@angular/router'; | ||||||
|  | import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||||
|  | import { | ||||||
|  |     AddonModGlossary, | ||||||
|  |     AddonModGlossaryEntry, | ||||||
|  |     AddonModGlossaryGetEntriesOptions, | ||||||
|  |     AddonModGlossaryGetEntriesWSResponse, | ||||||
|  |     AddonModGlossaryGlossary, | ||||||
|  |     AddonModGlossaryProvider, | ||||||
|  | } from '../services/glossary'; | ||||||
|  | import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from '../services/glossary-offline'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Provides a collection of glossary entries. | ||||||
|  |  */ | ||||||
|  | export class AddonModGlossaryEntriesSource extends CoreItemsManagerSource<AddonModGlossaryEntryItem> { | ||||||
|  | 
 | ||||||
|  |     static readonly NEW_ENTRY: AddonModGlossaryNewEntryForm = { newEntry: true }; | ||||||
|  | 
 | ||||||
|  |     readonly COURSE_ID: number; | ||||||
|  |     readonly CM_ID: number; | ||||||
|  |     readonly GLOSSARY_PATH_PREFIX: string; | ||||||
|  | 
 | ||||||
|  |     isSearch = false; | ||||||
|  |     hasSearched = false; | ||||||
|  |     fetchMode?: AddonModGlossaryFetchMode; | ||||||
|  |     viewMode?: string; | ||||||
|  |     glossary?: AddonModGlossaryGlossary; | ||||||
|  |     onlineEntries: AddonModGlossaryEntry[] = []; | ||||||
|  |     offlineEntries: AddonModGlossaryOfflineEntry[] = []; | ||||||
|  | 
 | ||||||
|  |     protected fetchFunction?: (options?: AddonModGlossaryGetEntriesOptions) => AddonModGlossaryGetEntriesWSResponse; | ||||||
|  |     protected fetchInvalidate?: () => Promise<void>; | ||||||
|  | 
 | ||||||
|  |     constructor(courseId: number, cmId: number, glossaryPathPrefix: string) { | ||||||
|  |         super(); | ||||||
|  | 
 | ||||||
|  |         this.COURSE_ID = courseId; | ||||||
|  |         this.CM_ID = cmId; | ||||||
|  |         this.GLOSSARY_PATH_PREFIX = glossaryPathPrefix; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Type guard to infer NewEntryForm objects. | ||||||
|  |      * | ||||||
|  |      * @param entry Item to check. | ||||||
|  |      * @return Whether the item is a new entry form. | ||||||
|  |      */ | ||||||
|  |     isNewEntryForm(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryNewEntryForm { | ||||||
|  |         return 'newEntry' in entry; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Type guard to infer entry objects. | ||||||
|  |      * | ||||||
|  |      * @param entry Item to check. | ||||||
|  |      * @return Whether the item is an offline entry. | ||||||
|  |      */ | ||||||
|  |     isOnlineEntry(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryEntry { | ||||||
|  |         return 'id' in entry; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Type guard to infer entry objects. | ||||||
|  |      * | ||||||
|  |      * @param entry Item to check. | ||||||
|  |      * @return Whether the item is an offline entry. | ||||||
|  |      */ | ||||||
|  |     isOfflineEntry(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryOfflineEntry { | ||||||
|  |         return !this.isNewEntryForm(entry) && !this.isOnlineEntry(entry); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     getItemPath(entry: AddonModGlossaryEntryItem): string { | ||||||
|  |         if (this.isOnlineEntry(entry)) { | ||||||
|  |             return `${this.GLOSSARY_PATH_PREFIX}entry/${entry.id}`; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if (this.isOfflineEntry(entry)) { | ||||||
|  |             return `${this.GLOSSARY_PATH_PREFIX}edit/${entry.timecreated}`; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return `${this.GLOSSARY_PATH_PREFIX}edit/0`; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     getItemQueryParams(entry: AddonModGlossaryEntryItem): Params { | ||||||
|  |         const params: Params = { | ||||||
|  |             cmId: this.CM_ID, | ||||||
|  |             courseId: this.COURSE_ID, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         if (this.isOfflineEntry(entry)) { | ||||||
|  |             params.concept = entry.concept; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return params; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     getPagesLoaded(): number { | ||||||
|  |         if (this.items === null) { | ||||||
|  |             return 0; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return Math.ceil(this.onlineEntries.length / this.getPageLength()); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Start searching. | ||||||
|  |      */ | ||||||
|  |     startSearch(): void { | ||||||
|  |         this.isSearch = true; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Stop searching and restore unfiltered collection. | ||||||
|  |      * | ||||||
|  |      * @param cachedOnlineEntries Cached online entries. | ||||||
|  |      * @param hasMoreOnlineEntries Whether there were more online entries. | ||||||
|  |      */ | ||||||
|  |     stopSearch(cachedOnlineEntries: AddonModGlossaryEntry[], hasMoreOnlineEntries: boolean): void { | ||||||
|  |         if (!this.fetchMode) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         this.isSearch = false; | ||||||
|  |         this.hasSearched = false; | ||||||
|  |         this.onlineEntries = cachedOnlineEntries; | ||||||
|  |         this.hasMoreItems = hasMoreOnlineEntries; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Set search query. | ||||||
|  |      * | ||||||
|  |      * @param query Search query. | ||||||
|  |      */ | ||||||
|  |     search(query: string): void { | ||||||
|  |         if (!this.glossary) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         this.fetchFunction = AddonModGlossary.getEntriesBySearch.bind( | ||||||
|  |             AddonModGlossary.instance, | ||||||
|  |             this.glossary.id, | ||||||
|  |             query, | ||||||
|  |             true, | ||||||
|  |             'CONCEPT', | ||||||
|  |             'ASC', | ||||||
|  |         ); | ||||||
|  |         this.fetchInvalidate = AddonModGlossary.invalidateEntriesBySearch.bind( | ||||||
|  |             AddonModGlossary.instance, | ||||||
|  |             this.glossary.id, | ||||||
|  |             query, | ||||||
|  |             true, | ||||||
|  |             'CONCEPT', | ||||||
|  |             'ASC', | ||||||
|  |         ); | ||||||
|  |         this.hasSearched = true; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Load glossary. | ||||||
|  |      */ | ||||||
|  |     async loadGlossary(): Promise<void> { | ||||||
|  |         this.glossary = await AddonModGlossary.getGlossary(this.COURSE_ID, this.CM_ID); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Invalidate glossary cache. | ||||||
|  |      */ | ||||||
|  |     async invalidateCache(): Promise<void> { | ||||||
|  |         await Promise.all([ | ||||||
|  |             AddonModGlossary.invalidateCourseGlossaries(this.COURSE_ID), | ||||||
|  |             this.fetchInvalidate && this.fetchInvalidate(), | ||||||
|  |             this.glossary && AddonModGlossary.invalidateCategories(this.glossary.id), | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Change fetch mode. | ||||||
|  |      * | ||||||
|  |      * @param mode New mode. | ||||||
|  |      */ | ||||||
|  |     switchMode(mode: AddonModGlossaryFetchMode): void { | ||||||
|  |         if (!this.glossary) { | ||||||
|  |             throw new Error('Can\'t switch entries mode without a glossary!'); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         this.fetchMode = mode; | ||||||
|  |         this.isSearch = false; | ||||||
|  | 
 | ||||||
|  |         switch (mode) { | ||||||
|  |             case 'author_all': | ||||||
|  |                 // Browse by author.
 | ||||||
|  |                 this.viewMode = 'author'; | ||||||
|  |                 this.fetchFunction = AddonModGlossary.getEntriesByAuthor.bind( | ||||||
|  |                     AddonModGlossary.instance, | ||||||
|  |                     this.glossary.id, | ||||||
|  |                     'ALL', | ||||||
|  |                     'LASTNAME', | ||||||
|  |                     'ASC', | ||||||
|  |                 ); | ||||||
|  |                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByAuthor.bind( | ||||||
|  |                     AddonModGlossary.instance, | ||||||
|  |                     this.glossary.id, | ||||||
|  |                     'ALL', | ||||||
|  |                     'LASTNAME', | ||||||
|  |                     'ASC', | ||||||
|  |                 ); | ||||||
|  |                 break; | ||||||
|  | 
 | ||||||
|  |             case 'cat_all': | ||||||
|  |                 // Browse by category.
 | ||||||
|  |                 this.viewMode = 'cat'; | ||||||
|  |                 this.fetchFunction = AddonModGlossary.getEntriesByCategory.bind( | ||||||
|  |                     AddonModGlossary.instance, | ||||||
|  |                     this.glossary.id, | ||||||
|  |                     AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, | ||||||
|  |                 ); | ||||||
|  |                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByCategory.bind( | ||||||
|  |                     AddonModGlossary.instance, | ||||||
|  |                     this.glossary.id, | ||||||
|  |                     AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, | ||||||
|  |                 ); | ||||||
|  |                 break; | ||||||
|  | 
 | ||||||
|  |             case 'newest_first': | ||||||
|  |                 // Newest first.
 | ||||||
|  |                 this.viewMode = 'date'; | ||||||
|  |                 this.fetchFunction = AddonModGlossary.getEntriesByDate.bind( | ||||||
|  |                     AddonModGlossary.instance, | ||||||
|  |                     this.glossary.id, | ||||||
|  |                     'CREATION', | ||||||
|  |                     'DESC', | ||||||
|  |                 ); | ||||||
|  |                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByDate.bind( | ||||||
|  |                     AddonModGlossary.instance, | ||||||
|  |                     this.glossary.id, | ||||||
|  |                     'CREATION', | ||||||
|  |                     'DESC', | ||||||
|  |                 ); | ||||||
|  |                 break; | ||||||
|  | 
 | ||||||
|  |             case 'recently_updated': | ||||||
|  |                 // Recently updated.
 | ||||||
|  |                 this.viewMode = 'date'; | ||||||
|  |                 this.fetchFunction = AddonModGlossary.getEntriesByDate.bind( | ||||||
|  |                     AddonModGlossary.instance, | ||||||
|  |                     this.glossary.id, | ||||||
|  |                     'UPDATE', | ||||||
|  |                     'DESC', | ||||||
|  |                 ); | ||||||
|  |                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByDate.bind( | ||||||
|  |                     AddonModGlossary.instance, | ||||||
|  |                     this.glossary.id, | ||||||
|  |                     'UPDATE', | ||||||
|  |                     'DESC', | ||||||
|  |                 ); | ||||||
|  |                 break; | ||||||
|  | 
 | ||||||
|  |             case 'letter_all': | ||||||
|  |             default: | ||||||
|  |                 // Consider it is 'letter_all'.
 | ||||||
|  |                 this.viewMode = 'letter'; | ||||||
|  |                 this.fetchMode = 'letter_all'; | ||||||
|  |                 this.fetchFunction = AddonModGlossary.getEntriesByLetter.bind( | ||||||
|  |                     AddonModGlossary.instance, | ||||||
|  |                     this.glossary.id, | ||||||
|  |                     'ALL', | ||||||
|  |                 ); | ||||||
|  |                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByLetter.bind( | ||||||
|  |                     AddonModGlossary.instance, | ||||||
|  |                     this.glossary.id, | ||||||
|  |                     'ALL', | ||||||
|  |                 ); | ||||||
|  |                 break; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     protected async loadPageItems(page: number): Promise<{ items: AddonModGlossaryEntryItem[]; hasMoreItems: boolean }> { | ||||||
|  |         const glossary = this.glossary; | ||||||
|  |         const fetchFunction = this.fetchFunction; | ||||||
|  | 
 | ||||||
|  |         if (!glossary || !fetchFunction) { | ||||||
|  |             throw new Error('Can\'t load entries without glossary or fetch function'); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const entries: AddonModGlossaryEntryItem[] = []; | ||||||
|  | 
 | ||||||
|  |         if (page === 0) { | ||||||
|  |             const offlineEntries = await AddonModGlossaryOffline.getGlossaryNewEntries(glossary.id); | ||||||
|  | 
 | ||||||
|  |             offlineEntries.sort((a, b) => a.concept.localeCompare(b.concept)); | ||||||
|  | 
 | ||||||
|  |             entries.push(AddonModGlossaryEntriesSource.NEW_ENTRY); | ||||||
|  |             entries.push(...offlineEntries); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const from = page * this.getPageLength(); | ||||||
|  |         const pageEntries = await fetchFunction({ from, cmId: this.CM_ID }); | ||||||
|  | 
 | ||||||
|  |         entries.push(...pageEntries.entries); | ||||||
|  | 
 | ||||||
|  |         return { | ||||||
|  |             items: entries, | ||||||
|  |             hasMoreItems: from + pageEntries.entries.length < pageEntries.count, | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     protected getPageLength(): number { | ||||||
|  |         return AddonModGlossaryProvider.LIMIT_ENTRIES; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     protected setItems(entries: AddonModGlossaryEntryItem[], hasMoreItems: boolean): void { | ||||||
|  |         this.onlineEntries = []; | ||||||
|  |         this.offlineEntries = []; | ||||||
|  | 
 | ||||||
|  |         entries.forEach(entry => { | ||||||
|  |             this.isOnlineEntry(entry) && this.onlineEntries.push(entry); | ||||||
|  |             this.isOfflineEntry(entry) && this.offlineEntries.push(entry); | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         super.setItems(entries, hasMoreItems); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     reset(): void { | ||||||
|  |         this.onlineEntries = []; | ||||||
|  |         this.offlineEntries = []; | ||||||
|  | 
 | ||||||
|  |         super.reset(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Type of items that can be held by the entries manager. | ||||||
|  |  */ | ||||||
|  | export type AddonModGlossaryEntryItem = AddonModGlossaryEntry | AddonModGlossaryOfflineEntry | AddonModGlossaryNewEntryForm; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Type to select the new entry form. | ||||||
|  |  */ | ||||||
|  | export type AddonModGlossaryNewEntryForm = { newEntry: true }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Fetch mode to sort entries. | ||||||
|  |  */ | ||||||
|  | export type AddonModGlossaryFetchMode = 'author_all' | 'cat_all' | 'newest_first' | 'recently_updated' | 'letter_all'; | ||||||
| @ -0,0 +1,52 @@ | |||||||
|  | // (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 { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||||
|  | import { AddonModGlossaryEntriesSource, AddonModGlossaryEntryItem } from './glossary-entries-source'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Helper to manage swiping within a collection of glossary entries. | ||||||
|  |  */ | ||||||
|  | export abstract class AddonModGlossaryEntriesSwipeManager | ||||||
|  |     extends CoreSwipeItemsManager<AddonModGlossaryEntryItem, AddonModGlossaryEntriesSource> { | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     async navigateToNextItem(): Promise<void> { | ||||||
|  |         let delta = -1; | ||||||
|  |         const item = await this.getItemBy(-1); | ||||||
|  | 
 | ||||||
|  |         if (item && this.getSource().isNewEntryForm(item)) { | ||||||
|  |             delta--; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         await this.navigateToItemBy(delta, 'back'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     async navigateToPreviousItem(): Promise<void> { | ||||||
|  |         let delta = 1; | ||||||
|  |         const item = await this.getItemBy(1); | ||||||
|  | 
 | ||||||
|  |         if (item && this.getSource().isNewEntryForm(item)) { | ||||||
|  |             delta++; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         await this.navigateToItemBy(delta, 'forward'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -54,7 +54,7 @@ | |||||||
|             [component]="component" [componentId]="componentId" [courseId]="courseId" [hasDataToSync]="hasOffline || hasOfflineRatings"> |             [component]="component" [componentId]="componentId" [courseId]="courseId" [hasDataToSync]="hasOffline || hasOfflineRatings"> | ||||||
|         </core-course-module-info> |         </core-course-module-info> | ||||||
| 
 | 
 | ||||||
|         <ion-list *ngIf="!isSearch && entries.offlineEntries.length > 0"> |         <ion-list *ngIf="!isSearch && entries && entries.offlineEntries.length > 0"> | ||||||
|             <ion-item-divider> |             <ion-item-divider> | ||||||
|                 <ion-label> |                 <ion-label> | ||||||
|                     <h2>{{ 'addon.mod_glossary.entriestobesynced' | translate }}</h2> |                     <h2>{{ 'addon.mod_glossary.entriestobesynced' | translate }}</h2> | ||||||
| @ -70,7 +70,7 @@ | |||||||
|             </ion-item> |             </ion-item> | ||||||
|         </ion-list> |         </ion-list> | ||||||
| 
 | 
 | ||||||
|         <ion-list *ngIf="entries.onlineEntries.length > 0"> |         <ion-list *ngIf="entries && entries.onlineEntries.length > 0"> | ||||||
|             <ng-container *ngFor="let entry of entries.onlineEntries; let index = index"> |             <ng-container *ngFor="let entry of entries.onlineEntries; let index = index"> | ||||||
|                 <ion-item-divider *ngIf="getDivider && showDivider(entry, entries.onlineEntries[index - 1])"> |                 <ion-item-divider *ngIf="getDivider && showDivider(entry, entries.onlineEntries[index - 1])"> | ||||||
|                     <ion-label> |                     <ion-label> | ||||||
| @ -88,11 +88,11 @@ | |||||||
|             </ng-container> |             </ng-container> | ||||||
|         </ion-list> |         </ion-list> | ||||||
| 
 | 
 | ||||||
|         <core-empty-box *ngIf="entries.empty && (!isSearch || hasSearched)" icon="fas-list" |         <core-empty-box *ngIf="(!entries || entries.empty) && (!isSearch || hasSearched)" icon="fas-list" | ||||||
|             [message]="'addon.mod_glossary.noentriesfound' | translate"> |             [message]="'addon.mod_glossary.noentriesfound' | translate"> | ||||||
|         </core-empty-box> |         </core-empty-box> | ||||||
| 
 | 
 | ||||||
|         <core-infinite-loading [enabled]="!entries.completed" [error]="loadMoreError" (action)="loadMoreEntries($event)"> |         <core-infinite-loading [enabled]="entries && !entries.completed" [error]="loadMoreError" (action)="loadMoreEntries($event)"> | ||||||
|         </core-infinite-loading> |         </core-infinite-loading> | ||||||
|     </core-loading> |     </core-loading> | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -14,8 +14,9 @@ | |||||||
| 
 | 
 | ||||||
| import { ContextLevel } from '@/core/constants'; | import { ContextLevel } from '@/core/constants'; | ||||||
| import { AfterViewInit, Component, OnDestroy, OnInit, Optional, ViewChild } from '@angular/core'; | import { AfterViewInit, Component, OnDestroy, OnInit, Optional, ViewChild } from '@angular/core'; | ||||||
| import { ActivatedRoute, Params } from '@angular/router'; | import { ActivatedRoute } from '@angular/router'; | ||||||
| import { CorePageItemsListManager } from '@classes/page-items-list-manager'; | import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||||
|  | import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; | ||||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||||
| import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; | import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; | ||||||
| import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; | import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; | ||||||
| @ -29,16 +30,19 @@ import { CoreDomUtils } from '@services/utils/dom'; | |||||||
| import { CoreTextUtils } from '@services/utils/text'; | import { CoreTextUtils } from '@services/utils/text'; | ||||||
| import { Translate } from '@singletons'; | import { Translate } from '@singletons'; | ||||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||||
|  | import { | ||||||
|  |     AddonModGlossaryEntriesSource, | ||||||
|  |     AddonModGlossaryEntryItem, | ||||||
|  |     AddonModGlossaryFetchMode, | ||||||
|  | } from '../../classes/glossary-entries-source'; | ||||||
| import { | import { | ||||||
|     AddonModGlossary, |     AddonModGlossary, | ||||||
|     AddonModGlossaryEntry, |     AddonModGlossaryEntry, | ||||||
|     AddonModGlossaryEntryWithCategory, |     AddonModGlossaryEntryWithCategory, | ||||||
|     AddonModGlossaryGetEntriesOptions, |  | ||||||
|     AddonModGlossaryGetEntriesWSResponse, |  | ||||||
|     AddonModGlossaryGlossary, |     AddonModGlossaryGlossary, | ||||||
|     AddonModGlossaryProvider, |     AddonModGlossaryProvider, | ||||||
| } from '../../services/glossary'; | } from '../../services/glossary'; | ||||||
| import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from '../../services/glossary-offline'; | import { AddonModGlossaryOfflineEntry } from '../../services/glossary-offline'; | ||||||
| import { | import { | ||||||
|     AddonModGlossaryAutoSyncData, |     AddonModGlossaryAutoSyncData, | ||||||
|     AddonModGlossarySyncProvider, |     AddonModGlossarySyncProvider, | ||||||
| @ -63,23 +67,17 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | |||||||
|     component = AddonModGlossaryProvider.COMPONENT; |     component = AddonModGlossaryProvider.COMPONENT; | ||||||
|     moduleName = 'glossary'; |     moduleName = 'glossary'; | ||||||
| 
 | 
 | ||||||
|     isSearch = false; |  | ||||||
|     hasSearched = false; |  | ||||||
|     canAdd = false; |     canAdd = false; | ||||||
|     loadMoreError = false; |     loadMoreError = false; | ||||||
|     loadingMessage?: string; |     loadingMessage: string; | ||||||
|     entries: AddonModGlossaryEntriesManager; |     entries!: AddonModGlossaryEntriesManager; | ||||||
|     hasOfflineRatings = false; |     hasOfflineRatings = false; | ||||||
|     glossary?: AddonModGlossaryGlossary; |  | ||||||
| 
 | 
 | ||||||
|     protected syncEventName = AddonModGlossarySyncProvider.AUTO_SYNCED; |     protected syncEventName = AddonModGlossarySyncProvider.AUTO_SYNCED; | ||||||
|     protected fetchFunction?: (options?: AddonModGlossaryGetEntriesOptions) => AddonModGlossaryGetEntriesWSResponse; |  | ||||||
|     protected fetchInvalidate?: () => Promise<void>; |  | ||||||
|     protected addEntryObserver?: CoreEventObserver; |     protected addEntryObserver?: CoreEventObserver; | ||||||
|     protected fetchMode?: AddonModGlossaryFetchMode; |  | ||||||
|     protected viewMode?: string; |  | ||||||
|     protected fetchedEntriesCanLoadMore = false; |     protected fetchedEntriesCanLoadMore = false; | ||||||
|     protected fetchedEntries: AddonModGlossaryEntry[] = []; |     protected fetchedEntries: AddonModGlossaryEntry[] = []; | ||||||
|  |     protected sourceUnsubscribe?: () => void; | ||||||
|     protected ratingOfflineObserver?: CoreEventObserver; |     protected ratingOfflineObserver?: CoreEventObserver; | ||||||
|     protected ratingSyncObserver?: CoreEventObserver; |     protected ratingSyncObserver?: CoreEventObserver; | ||||||
| 
 | 
 | ||||||
| @ -87,26 +85,47 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | |||||||
|     showDivider: (entry: AddonModGlossaryEntry, previous?: AddonModGlossaryEntry) => boolean = () => false; |     showDivider: (entry: AddonModGlossaryEntry, previous?: AddonModGlossaryEntry) => boolean = () => false; | ||||||
| 
 | 
 | ||||||
|     constructor( |     constructor( | ||||||
|         route: ActivatedRoute, |         protected route: ActivatedRoute, | ||||||
|         protected content?: IonContent, |         protected content?: IonContent, | ||||||
|         @Optional() courseContentsPage?: CoreCourseContentsPage, |         @Optional() protected courseContentsPage?: CoreCourseContentsPage, | ||||||
|     ) { |     ) { | ||||||
|         super('AddonModGlossaryIndexComponent', content, courseContentsPage); |         super('AddonModGlossaryIndexComponent', content, courseContentsPage); | ||||||
| 
 | 
 | ||||||
|         this.entries = new AddonModGlossaryEntriesManager( |         this.loadingMessage = Translate.instant('core.loading'); | ||||||
|             route.component, |     } | ||||||
|             this, | 
 | ||||||
|             courseContentsPage ? `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` : '', |     get glossary(): AddonModGlossaryGlossary | undefined { | ||||||
|         ); |         return this.entries.getSource().glossary; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     get isSearch(): boolean { | ||||||
|  |         return this.entries.getSource().isSearch; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     get hasSearched(): boolean { | ||||||
|  |         return this.entries.getSource().hasSearched; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     async ngOnInit(): Promise<void> { |     async ngOnInit(): Promise<void> { | ||||||
|         super.ngOnInit(); |         await super.ngOnInit(); | ||||||
| 
 | 
 | ||||||
|         this.loadingMessage = Translate.instant('core.loading'); |         // Initialize entries manager.
 | ||||||
|  |         const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||||
|  |             AddonModGlossaryEntriesSource, | ||||||
|  |             [this.courseId, this.module.id, this.courseContentsPage ? `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` : ''], | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         this.entries = new AddonModGlossaryEntriesManager( | ||||||
|  |             source, | ||||||
|  |             this.route.component, | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         this.sourceUnsubscribe = source.addListener({ | ||||||
|  |             onItemsUpdated: items => this.hasOffline = !!items.find(item => source.isOfflineEntry(item)), | ||||||
|  |         }); | ||||||
| 
 | 
 | ||||||
|         // When an entry is added, we reload the data.
 |         // When an entry is added, we reload the data.
 | ||||||
|         this.addEntryObserver = CoreEvents.on(AddonModGlossaryProvider.ADD_ENTRY_EVENT, (data) => { |         this.addEntryObserver = CoreEvents.on(AddonModGlossaryProvider.ADD_ENTRY_EVENT, (data) => { | ||||||
| @ -143,11 +162,9 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | |||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         this.entries.start(this.splitView); |         await this.entries.start(this.splitView); | ||||||
| 
 | 
 | ||||||
|         try { |         try { | ||||||
|             await AddonModGlossary.logView(this.glossary.id, this.viewMode!, this.glossary.name); |  | ||||||
| 
 |  | ||||||
|             CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); |             CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             // Ignore errors.
 |             // Ignore errors.
 | ||||||
| @ -159,14 +176,18 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | |||||||
|      */ |      */ | ||||||
|     protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> { |     protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> { | ||||||
|         try { |         try { | ||||||
|             this.glossary = await AddonModGlossary.getGlossary(this.courseId, this.module.id); |             await this.entries.getSource().loadGlossary(); | ||||||
|  | 
 | ||||||
|  |             if (!this.glossary) { | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
| 
 | 
 | ||||||
|             this.description = this.glossary.intro || this.description; |             this.description = this.glossary.intro || this.description; | ||||||
|             this.canAdd = !!this.glossary.canaddentry || false; |             this.canAdd = !!this.glossary.canaddentry || false; | ||||||
| 
 | 
 | ||||||
|             this.dataRetrieved.emit(this.glossary); |             this.dataRetrieved.emit(this.glossary); | ||||||
| 
 | 
 | ||||||
|             if (!this.fetchMode) { |             if (!this.entries.getSource().fetchMode) { | ||||||
|                 this.switchMode('letter_all'); |                 this.switchMode('letter_all'); | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
| @ -177,7 +198,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | |||||||
| 
 | 
 | ||||||
|             const [hasOfflineRatings] = await Promise.all([ |             const [hasOfflineRatings] = await Promise.all([ | ||||||
|                 CoreRatingOffline.hasRatings('mod_glossary', 'entry', ContextLevel.MODULE, this.glossary.coursemodule), |                 CoreRatingOffline.hasRatings('mod_glossary', 'entry', ContextLevel.MODULE, this.glossary.coursemodule), | ||||||
|                 this.fetchEntries(), |                 refresh ? this.entries.reload() : this.entries.load(), | ||||||
|             ]); |             ]); | ||||||
| 
 | 
 | ||||||
|             this.hasOfflineRatings = hasOfflineRatings; |             this.hasOfflineRatings = hasOfflineRatings; | ||||||
| @ -186,59 +207,11 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |  | ||||||
|      * Convenience function to fetch entries. |  | ||||||
|      * |  | ||||||
|      * @param append True if fetched entries are appended to exsiting ones. |  | ||||||
|      * @return Promise resolved when done. |  | ||||||
|      */ |  | ||||||
|     protected async fetchEntries(append: boolean = false): Promise<void> { |  | ||||||
|         if (!this.fetchFunction) { |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         this.loadMoreError = false; |  | ||||||
|         const from = append ? this.entries.onlineEntries.length : 0; |  | ||||||
| 
 |  | ||||||
|         const result = await this.fetchFunction({ |  | ||||||
|             from: from, |  | ||||||
|             cmId: this.module.id, |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         const hasMoreEntries = from + result.entries.length < result.count; |  | ||||||
| 
 |  | ||||||
|         if (append) { |  | ||||||
|             this.entries.setItems(this.entries.items.concat(result.entries), hasMoreEntries); |  | ||||||
|         } else { |  | ||||||
|             this.entries.setOnlineEntries(result.entries, hasMoreEntries); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // Now get the ofline entries.
 |  | ||||||
|         // Check if there are responses stored in offline.
 |  | ||||||
|         const offlineEntries = await AddonModGlossaryOffline.getGlossaryNewEntries(this.glossary!.id); |  | ||||||
| 
 |  | ||||||
|         offlineEntries.sort((a, b) => a.concept.localeCompare(b.concept)); |  | ||||||
|         this.hasOffline = !!offlineEntries.length; |  | ||||||
|         this.entries.setOfflineEntries(offlineEntries); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |     /** | ||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     protected async invalidateContent(): Promise<void> { |     protected async invalidateContent(): Promise<void> { | ||||||
|         const promises: Promise<void>[] = []; |         await this.entries.getSource().invalidateCache(); | ||||||
| 
 |  | ||||||
|         if (this.fetchInvalidate) { |  | ||||||
|             promises.push(this.fetchInvalidate()); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         promises.push(AddonModGlossary.invalidateCourseGlossaries(this.courseId)); |  | ||||||
| 
 |  | ||||||
|         if (this.glossary) { |  | ||||||
|             promises.push(AddonModGlossary.invalidateCategories(this.glossary.id)); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         await Promise.all(promises); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -277,109 +250,50 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | |||||||
|      * @param mode New mode. |      * @param mode New mode. | ||||||
|      */ |      */ | ||||||
|     protected switchMode(mode: AddonModGlossaryFetchMode): void { |     protected switchMode(mode: AddonModGlossaryFetchMode): void { | ||||||
|         this.fetchMode = mode; |         this.entries.getSource().switchMode(mode); | ||||||
|         this.isSearch = false; |  | ||||||
| 
 | 
 | ||||||
|         switch (mode) { |         switch (mode) { | ||||||
|             case 'author_all': |             case 'author_all': | ||||||
|                 // Browse by author.
 |                 // Browse by author.
 | ||||||
|                 this.viewMode = 'author'; |  | ||||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByAuthor.bind( |  | ||||||
|                     AddonModGlossary.instance, |  | ||||||
|                     this.glossary!.id, |  | ||||||
|                     'ALL', |  | ||||||
|                     'LASTNAME', |  | ||||||
|                     'ASC', |  | ||||||
|                 ); |  | ||||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByAuthor.bind( |  | ||||||
|                     AddonModGlossary.instance, |  | ||||||
|                     this.glossary!.id, |  | ||||||
|                     'ALL', |  | ||||||
|                     'LASTNAME', |  | ||||||
|                     'ASC', |  | ||||||
|                 ); |  | ||||||
|                 this.getDivider = (entry) => entry.userfullname; |                 this.getDivider = (entry) => entry.userfullname; | ||||||
|                 this.showDivider = (entry, previous) => !previous || entry.userid != previous.userid; |                 this.showDivider = (entry, previous) => !previous || entry.userid != previous.userid; | ||||||
|                 break; |                 break; | ||||||
| 
 | 
 | ||||||
|             case 'cat_all': |             case 'cat_all': { | ||||||
|                 // Browse by category.
 |                 // Browse by category.
 | ||||||
|                 this.viewMode = 'cat'; |                 const getDivider = (entry: AddonModGlossaryEntryWithCategory) => entry.categoryname || ''; | ||||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByCategory.bind( | 
 | ||||||
|                     AddonModGlossary.instance, |                 this.getDivider = getDivider; | ||||||
|                     this.glossary!.id, |                 this.showDivider = (entry, previous) => !previous || getDivider(entry) != getDivider(previous); | ||||||
|                     AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, |  | ||||||
|                 ); |  | ||||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByCategory.bind( |  | ||||||
|                     AddonModGlossary.instance, |  | ||||||
|                     this.glossary!.id, |  | ||||||
|                     AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, |  | ||||||
|                 ); |  | ||||||
|                 this.getDivider = (entry: AddonModGlossaryEntryWithCategory) => entry.categoryname || ''; |  | ||||||
|                 this.showDivider = (entry, previous) => !previous || this.getDivider!(entry) != this.getDivider!(previous); |  | ||||||
|                 break; |                 break; | ||||||
|  |             } | ||||||
| 
 | 
 | ||||||
|             case 'newest_first': |             case 'newest_first': | ||||||
|                 // Newest first.
 |                 // Newest first.
 | ||||||
|                 this.viewMode = 'date'; |  | ||||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByDate.bind( |  | ||||||
|                     AddonModGlossary.instance, |  | ||||||
|                     this.glossary!.id, |  | ||||||
|                     'CREATION', |  | ||||||
|                     'DESC', |  | ||||||
|                 ); |  | ||||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByDate.bind( |  | ||||||
|                     AddonModGlossary.instance, |  | ||||||
|                     this.glossary!.id, |  | ||||||
|                     'CREATION', |  | ||||||
|                     'DESC', |  | ||||||
|                 ); |  | ||||||
|                 this.getDivider = undefined; |                 this.getDivider = undefined; | ||||||
|                 this.showDivider = () => false; |                 this.showDivider = () => false; | ||||||
|                 break; |                 break; | ||||||
| 
 | 
 | ||||||
|             case 'recently_updated': |             case 'recently_updated': | ||||||
|                 // Recently updated.
 |                 // Recently updated.
 | ||||||
|                 this.viewMode = 'date'; |  | ||||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByDate.bind( |  | ||||||
|                     AddonModGlossary.instance, |  | ||||||
|                     this.glossary!.id, |  | ||||||
|                     'UPDATE', |  | ||||||
|                     'DESC', |  | ||||||
|                 ); |  | ||||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByDate.bind( |  | ||||||
|                     AddonModGlossary.instance, |  | ||||||
|                     this.glossary!.id, |  | ||||||
|                     'UPDATE', |  | ||||||
|                     'DESC', |  | ||||||
|                 ); |  | ||||||
|                 this.getDivider = undefined; |                 this.getDivider = undefined; | ||||||
|                 this.showDivider = () => false; |                 this.showDivider = () => false; | ||||||
|                 break; |                 break; | ||||||
| 
 | 
 | ||||||
|             case 'letter_all': |             case 'letter_all': | ||||||
|             default: |             default: { | ||||||
|                 // Consider it is 'letter_all'.
 |                 // Consider it is 'letter_all'.
 | ||||||
|                 this.viewMode = 'letter'; |                 const getDivider = (entry) => { | ||||||
|                 this.fetchMode = 'letter_all'; |  | ||||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByLetter.bind( |  | ||||||
|                     AddonModGlossary.instance, |  | ||||||
|                     this.glossary!.id, |  | ||||||
|                     'ALL', |  | ||||||
|                 ); |  | ||||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByLetter.bind( |  | ||||||
|                     AddonModGlossary.instance, |  | ||||||
|                     this.glossary!.id, |  | ||||||
|                     'ALL', |  | ||||||
|                 ); |  | ||||||
|                 this.getDivider = (entry) => { |  | ||||||
|                     // Try to get the first letter without HTML tags.
 |                     // Try to get the first letter without HTML tags.
 | ||||||
|                     const noTags = CoreTextUtils.cleanTags(entry.concept); |                     const noTags = CoreTextUtils.cleanTags(entry.concept); | ||||||
| 
 | 
 | ||||||
|                     return (noTags || entry.concept).substr(0, 1).toUpperCase(); |                     return (noTags || entry.concept).substr(0, 1).toUpperCase(); | ||||||
|                 }; |                 }; | ||||||
|                 this.showDivider = (entry, previous) => !previous || this.getDivider!(entry) != this.getDivider!(previous); | 
 | ||||||
|  |                 this.getDivider = getDivider; | ||||||
|  |                 this.showDivider = (entry, previous) => !previous || getDivider(entry) != getDivider(previous); | ||||||
|                 break; |                 break; | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -391,7 +305,9 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | |||||||
|      */ |      */ | ||||||
|     async loadMoreEntries(infiniteComplete?: () => void): Promise<void> { |     async loadMoreEntries(infiniteComplete?: () => void): Promise<void> { | ||||||
|         try { |         try { | ||||||
|             await this.fetchEntries(true); |             this.loadMoreError = false; | ||||||
|  | 
 | ||||||
|  |             await this.entries.load(); | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             this.loadMoreError = true; |             this.loadMoreError = true; | ||||||
|             CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentries', true); |             CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentries', true); | ||||||
| @ -406,21 +322,34 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | |||||||
|      * @param event Event. |      * @param event Event. | ||||||
|      */ |      */ | ||||||
|     async openModePicker(event: MouseEvent): Promise<void> { |     async openModePicker(event: MouseEvent): Promise<void> { | ||||||
|         const mode = await CoreDomUtils.openPopover<AddonModGlossaryFetchMode>({ |         if (!this.glossary) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const previousMode = this.entries.getSource().fetchMode; | ||||||
|  |         const newMode = await CoreDomUtils.openPopover<AddonModGlossaryFetchMode>({ | ||||||
|             component: AddonModGlossaryModePickerPopoverComponent, |             component: AddonModGlossaryModePickerPopoverComponent, | ||||||
|             componentProps: { |             componentProps: { | ||||||
|                 browseModes: this.glossary!.browsemodes, |                 browseModes: this.glossary.browsemodes, | ||||||
|                 selectedMode: this.isSearch ? '' : this.fetchMode, |                 selectedMode: this.isSearch ? '' : previousMode, | ||||||
|             }, |             }, | ||||||
|             event, |             event, | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         if (mode) { |         if (!newMode) { | ||||||
|             if (mode !== this.fetchMode) { |             return; | ||||||
|                 this.changeFetchMode(mode); |         } | ||||||
|             } else if (this.isSearch) { | 
 | ||||||
|                 this.toggleSearch(); |         if (newMode !== previousMode) { | ||||||
|             } |             this.changeFetchMode(newMode); | ||||||
|  | 
 | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if (this.isSearch) { | ||||||
|  |             this.toggleSearch(); | ||||||
|  | 
 | ||||||
|  |             return; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -429,20 +358,22 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | |||||||
|      */ |      */ | ||||||
|     toggleSearch(): void { |     toggleSearch(): void { | ||||||
|         if (this.isSearch) { |         if (this.isSearch) { | ||||||
|             this.isSearch = false; |             const fetchMode = this.entries.getSource().fetchMode; | ||||||
|             this.hasSearched = false; |  | ||||||
|             this.entries.setOnlineEntries(this.fetchedEntries, this.fetchedEntriesCanLoadMore); |  | ||||||
|             this.switchMode(this.fetchMode!); |  | ||||||
|         } else { |  | ||||||
|             // Search for entries. The fetch function will be set when searching.
 |  | ||||||
|             this.getDivider = undefined; |  | ||||||
|             this.showDivider = () => false; |  | ||||||
|             this.isSearch = true; |  | ||||||
| 
 | 
 | ||||||
|             this.fetchedEntries = this.entries.onlineEntries; |             fetchMode && this.switchMode(fetchMode); | ||||||
|             this.fetchedEntriesCanLoadMore = !this.entries.completed; |             this.entries.getSource().stopSearch(this.fetchedEntries, this.fetchedEntriesCanLoadMore); | ||||||
|             this.entries.setItems([], false); | 
 | ||||||
|  |             return; | ||||||
|         } |         } | ||||||
|  | 
 | ||||||
|  |         // Search for entries. The fetch function will be set when searching.
 | ||||||
|  |         this.fetchedEntries = this.entries.getSource().onlineEntries; | ||||||
|  |         this.fetchedEntriesCanLoadMore = !this.entries.completed; | ||||||
|  |         this.getDivider = undefined; | ||||||
|  |         this.showDivider = () => false; | ||||||
|  | 
 | ||||||
|  |         this.entries.reset(); | ||||||
|  |         this.entries.getSource().startSearch(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -451,7 +382,6 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | |||||||
|      * @param mode Mode. |      * @param mode Mode. | ||||||
|      */ |      */ | ||||||
|     changeFetchMode(mode: AddonModGlossaryFetchMode): void { |     changeFetchMode(mode: AddonModGlossaryFetchMode): void { | ||||||
|         this.isSearch = false; |  | ||||||
|         this.loadingMessage = Translate.instant('core.loading'); |         this.loadingMessage = Translate.instant('core.loading'); | ||||||
|         this.content?.scrollToTop(); |         this.content?.scrollToTop(); | ||||||
|         this.switchMode(mode); |         this.switchMode(mode); | ||||||
| @ -463,7 +393,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | |||||||
|      * Opens new entry editor. |      * Opens new entry editor. | ||||||
|      */ |      */ | ||||||
|     openNewEntry(): void { |     openNewEntry(): void { | ||||||
|         this.entries.select({ newEntry: true }); |         this.entries.select(AddonModGlossaryEntriesSource.NEW_ENTRY); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -473,24 +403,9 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | |||||||
|      */ |      */ | ||||||
|     search(query: string): void { |     search(query: string): void { | ||||||
|         this.loadingMessage = Translate.instant('core.searching'); |         this.loadingMessage = Translate.instant('core.searching'); | ||||||
|         this.fetchFunction = AddonModGlossary.getEntriesBySearch.bind( |  | ||||||
|             AddonModGlossary.instance, |  | ||||||
|             this.glossary!.id, |  | ||||||
|             query, |  | ||||||
|             true, |  | ||||||
|             'CONCEPT', |  | ||||||
|             'ASC', |  | ||||||
|         ); |  | ||||||
|         this.fetchInvalidate = AddonModGlossary.invalidateEntriesBySearch.bind( |  | ||||||
|             AddonModGlossary.instance, |  | ||||||
|             this.glossary!.id, |  | ||||||
|             query, |  | ||||||
|             true, |  | ||||||
|             'CONCEPT', |  | ||||||
|             'ASC', |  | ||||||
|         ); |  | ||||||
|         this.loaded = false; |         this.loaded = false; | ||||||
|         this.hasSearched = true; | 
 | ||||||
|  |         this.entries.getSource().search(query); | ||||||
|         this.loadContent(); |         this.loadContent(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -503,154 +418,44 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | |||||||
|         this.addEntryObserver?.off(); |         this.addEntryObserver?.off(); | ||||||
|         this.ratingOfflineObserver?.off(); |         this.ratingOfflineObserver?.off(); | ||||||
|         this.ratingSyncObserver?.off(); |         this.ratingSyncObserver?.off(); | ||||||
|  |         this.sourceUnsubscribe?.call(null); | ||||||
|  |         this.entries.destroy(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** |  | ||||||
|  * Type to select the new entry form. |  | ||||||
|  */ |  | ||||||
| type NewEntryForm = { newEntry: true }; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Type of items that can be held by the entries manager. |  | ||||||
|  */ |  | ||||||
| type EntryItem = AddonModGlossaryEntry | AddonModGlossaryOfflineEntry | NewEntryForm; |  | ||||||
| 
 |  | ||||||
| /** | /** | ||||||
|  * Entries manager. |  * Entries manager. | ||||||
|  */ |  */ | ||||||
| class AddonModGlossaryEntriesManager extends CorePageItemsListManager<EntryItem> { | class AddonModGlossaryEntriesManager extends CoreListItemsManager<AddonModGlossaryEntryItem, AddonModGlossaryEntriesSource> { | ||||||
| 
 | 
 | ||||||
|     onlineEntries: AddonModGlossaryEntry[] = []; |     get offlineEntries(): AddonModGlossaryOfflineEntry[] { | ||||||
|     offlineEntries: AddonModGlossaryOfflineEntry[] = []; |         return this.getSource().offlineEntries; | ||||||
| 
 |  | ||||||
|     protected glossaryPathPrefix: string; |  | ||||||
|     protected component: AddonModGlossaryIndexComponent; |  | ||||||
| 
 |  | ||||||
|     constructor( |  | ||||||
|         pageComponent: unknown, |  | ||||||
|         component: AddonModGlossaryIndexComponent, |  | ||||||
|         glossaryPathPrefix: string, |  | ||||||
|     ) { |  | ||||||
|         super(pageComponent); |  | ||||||
| 
 |  | ||||||
|         this.component = component; |  | ||||||
|         this.glossaryPathPrefix = glossaryPathPrefix; |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     get onlineEntries(): AddonModGlossaryEntry[] { | ||||||
|      * Type guard to infer NewEntryForm objects. |         return this.getSource().onlineEntries; | ||||||
|      * |  | ||||||
|      * @param entry Item to check. |  | ||||||
|      * @return Whether the item is a new entry form. |  | ||||||
|      */ |  | ||||||
|     isNewEntryForm(entry: EntryItem): entry is NewEntryForm { |  | ||||||
|         return 'newEntry' in entry; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Type guard to infer entry objects. |  | ||||||
|      * |  | ||||||
|      * @param entry Item to check. |  | ||||||
|      * @return Whether the item is an offline entry. |  | ||||||
|      */ |  | ||||||
|     isOfflineEntry(entry: EntryItem): entry is AddonModGlossaryOfflineEntry { |  | ||||||
|         return !this.isNewEntryForm(entry) && !this.isOnlineEntry(entry); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Type guard to infer entry objects. |  | ||||||
|      * |  | ||||||
|      * @param entry Item to check. |  | ||||||
|      * @return Whether the item is an offline entry. |  | ||||||
|      */ |  | ||||||
|     isOnlineEntry(entry: EntryItem): entry is AddonModGlossaryEntry { |  | ||||||
|         return 'id' in entry; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Update online entries items. |  | ||||||
|      * |  | ||||||
|      * @param onlineEntries Online entries. |  | ||||||
|      */ |  | ||||||
|     setOnlineEntries(onlineEntries: AddonModGlossaryEntry[], hasMoreItems: boolean = false): void { |  | ||||||
|         this.setItems((<EntryItem[]> this.offlineEntries).concat(onlineEntries), hasMoreItems); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Update offline entries items. |  | ||||||
|      * |  | ||||||
|      * @param offlineEntries Offline entries. |  | ||||||
|      */ |  | ||||||
|     setOfflineEntries(offlineEntries: AddonModGlossaryOfflineEntry[]): void { |  | ||||||
|         this.setItems((<EntryItem[]> offlineEntries).concat(this.onlineEntries), this.hasMoreItems); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     setItems(entries: EntryItem[], hasMoreItems: boolean = false): void { |     protected getDefaultItem(): AddonModGlossaryEntryItem | null { | ||||||
|         super.setItems(entries, hasMoreItems); |         return this.getSource().onlineEntries[0] || null; | ||||||
| 
 |  | ||||||
|         this.onlineEntries = []; |  | ||||||
|         this.offlineEntries = []; |  | ||||||
|         this.items.forEach(entry => { |  | ||||||
|             if (this.isOfflineEntry(entry)) { |  | ||||||
|                 this.offlineEntries.push(entry); |  | ||||||
|             } else if (this.isOnlineEntry(entry)) { |  | ||||||
|                 this.onlineEntries.push(entry); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     resetItems(): void { |     protected async logActivity(): Promise<void> { | ||||||
|         super.resetItems(); |         const glossary = this.getSource().glossary; | ||||||
|         this.onlineEntries = []; |         const viewMode = this.getSource().viewMode; | ||||||
|         this.offlineEntries = []; |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     /** |         if (!glossary || !viewMode) { | ||||||
|      * @inheritdoc |             return; | ||||||
|      */ |  | ||||||
|     protected getItemPath(entry: EntryItem): string { |  | ||||||
|         if (this.isOnlineEntry(entry)) { |  | ||||||
|             return `${this.glossaryPathPrefix}entry/${entry.id}`; |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (this.isOfflineEntry(entry)) { |         await AddonModGlossary.logView(glossary.id, viewMode, glossary.name); | ||||||
|             return `${this.glossaryPathPrefix}edit/${entry.timecreated}`; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return `${this.glossaryPathPrefix}edit/0`; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * @inheritdoc |  | ||||||
|      */ |  | ||||||
|     getItemQueryParams(entry: EntryItem): Params { |  | ||||||
|         const params: Params = { |  | ||||||
|             cmId: this.component.module.id, |  | ||||||
|             courseId: this.component.courseId, |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         if (this.isOfflineEntry(entry)) { |  | ||||||
|             params.concept = entry.concept; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return params; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * @inheritdoc |  | ||||||
|      */ |  | ||||||
|     protected getDefaultItem(): EntryItem | null { |  | ||||||
|         return this.onlineEntries[0] || null; |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| } | } | ||||||
| 
 |  | ||||||
| export type AddonModGlossaryFetchMode = 'author_all' | 'cat_all' | 'newest_first' | 'recently_updated' | 'letter_all'; |  | ||||||
|  | |||||||
| @ -14,7 +14,7 @@ | |||||||
| 
 | 
 | ||||||
| import { Component, Input, OnInit } from '@angular/core'; | import { Component, Input, OnInit } from '@angular/core'; | ||||||
| import { PopoverController } from '@singletons'; | import { PopoverController } from '@singletons'; | ||||||
| import { AddonModGlossaryFetchMode } from '../index'; | import { AddonModGlossaryFetchMode } from '../../classes/glossary-entries-source'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Component to display the mode picker. |  * Component to display the mode picker. | ||||||
|  | |||||||
| @ -51,10 +51,12 @@ const mainMenuRoutes: Routes = [ | |||||||
|     { |     { | ||||||
|         path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`, |         path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`, | ||||||
|         loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule), |         loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule), | ||||||
|  |         data: { swipeEnabled: false }, | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|         path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`, |         path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`, | ||||||
|         loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule), |         loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule), | ||||||
|  |         data: { swipeEnabled: false }, | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|         path: AddonModGlossaryModuleHandlerService.PAGE_NAME, |         path: AddonModGlossaryModuleHandlerService.PAGE_NAME, | ||||||
| @ -65,10 +67,12 @@ const mainMenuRoutes: Routes = [ | |||||||
|             { |             { | ||||||
|                 path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`, |                 path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`, | ||||||
|                 loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule), |                 loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule), | ||||||
|  |                 data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, | ||||||
|             }, |             }, | ||||||
|             { |             { | ||||||
|                 path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`, |                 path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`, | ||||||
|                 loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule), |                 loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule), | ||||||
|  |                 data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, | ||||||
|             }, |             }, | ||||||
|         ], |         ], | ||||||
|         () => CoreScreen.isMobile, |         () => CoreScreen.isMobile, | ||||||
| @ -80,10 +84,12 @@ const courseContentsRoutes: Routes = conditionalRoutes( | |||||||
|         { |         { | ||||||
|             path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`, |             path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`, | ||||||
|             loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule), |             loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule), | ||||||
|  |             data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|             path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`, |             path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`, | ||||||
|             loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule), |             loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule), | ||||||
|  |             data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, | ||||||
|         }, |         }, | ||||||
|     ], |     ], | ||||||
|     () => CoreScreen.isTablet, |     () => CoreScreen.isTablet, | ||||||
|  | |||||||
| @ -12,72 +12,75 @@ | |||||||
|     </ion-toolbar> |     </ion-toolbar> | ||||||
| </ion-header> | </ion-header> | ||||||
| <ion-content> | <ion-content> | ||||||
|     <core-loading [hideUntil]="loaded"> |     <core-swipe-navigation [manager]="entries"> | ||||||
|         <form #editFormEl *ngIf="glossary"> |         <core-loading [hideUntil]="loaded"> | ||||||
|             <ion-item> |             <form #editFormEl *ngIf="glossary"> | ||||||
|                 <ion-label position="stacked">{{ 'addon.mod_glossary.concept' | translate }}</ion-label> |                 <ion-item> | ||||||
|                 <ion-input type="text" [placeholder]="'addon.mod_glossary.concept' | translate" [(ngModel)]="entry.concept" name="concept"> |                     <ion-label position="stacked">{{ 'addon.mod_glossary.concept' | translate }}</ion-label> | ||||||
|                 </ion-input> |                     <ion-input type="text" [placeholder]="'addon.mod_glossary.concept' | translate" [(ngModel)]="entry.concept" | ||||||
|             </ion-item> |                         name="concept"> | ||||||
|             <ion-item> |                     </ion-input> | ||||||
|                 <ion-label position="stacked">{{ 'addon.mod_glossary.definition' | translate }}</ion-label> |                 </ion-item> | ||||||
|                 <core-rich-text-editor [control]="definitionControl" (contentChanged)="onDefinitionChange($event)" |                 <ion-item> | ||||||
|                     [placeholder]="'addon.mod_glossary.definition' | translate" name="addon_mod_glossary_edit" [component]="component" |                     <ion-label position="stacked">{{ 'addon.mod_glossary.definition' | translate }}</ion-label> | ||||||
|                     [componentId]="cmId" [autoSave]="true" contextLevel="module" [contextInstanceId]="cmId" elementId="definition_editor" |                     <core-rich-text-editor [control]="definitionControl" (contentChanged)="onDefinitionChange($event)" | ||||||
|                     [draftExtraParams]="editorExtraParams"> |                         [placeholder]="'addon.mod_glossary.definition' | translate" name="addon_mod_glossary_edit" [component]="component" | ||||||
|                 </core-rich-text-editor> |                         [componentId]="cmId" [autoSave]="true" contextLevel="module" [contextInstanceId]="cmId" | ||||||
|             </ion-item> |                         elementId="definition_editor" [draftExtraParams]="editorExtraParams"> | ||||||
|             <ion-item *ngIf="categories.length > 0"> |                     </core-rich-text-editor> | ||||||
|                 <ion-label position="stacked" id="addon-mod-glossary-categories-label"> |                 </ion-item> | ||||||
|                     {{ 'addon.mod_glossary.categories' | translate }} |                 <ion-item *ngIf="categories.length > 0"> | ||||||
|                 </ion-label> |                     <ion-label position="stacked" id="addon-mod-glossary-categories-label"> | ||||||
|                 <ion-select [(ngModel)]="options.categories" multiple="true" aria-labelledby="addon-mod-glossary-categories-label" |                         {{ 'addon.mod_glossary.categories' | translate }} | ||||||
|                     interface="action-sheet" [placeholder]="'addon.mod_glossary.categories' | translate" name="categories" |                     </ion-label> | ||||||
|                     [interfaceOptions]="{header: 'addon.mod_glossary.categories' | translate}"> |                     <ion-select [(ngModel)]="options.categories" multiple="true" aria-labelledby="addon-mod-glossary-categories-label" | ||||||
|                     <ion-select-option *ngFor="let category of categories" [value]="category.id"> |                         interface="action-sheet" [placeholder]="'addon.mod_glossary.categories' | translate" name="categories" | ||||||
|                         {{ category.name }} |                         [interfaceOptions]="{header: 'addon.mod_glossary.categories' | translate}"> | ||||||
|                     </ion-select-option> |                         <ion-select-option *ngFor="let category of categories" [value]="category.id"> | ||||||
|                 </ion-select> |                             {{ category.name }} | ||||||
|             </ion-item> |                         </ion-select-option> | ||||||
|             <ion-item> |                     </ion-select> | ||||||
|                 <ion-label position="stacked" id="addon-mod-glossary-aliases-label"> |                 </ion-item> | ||||||
|                     {{ 'addon.mod_glossary.aliases' | translate }} |                 <ion-item> | ||||||
|                 </ion-label> |                     <ion-label position="stacked" id="addon-mod-glossary-aliases-label"> | ||||||
|                 <ion-textarea [(ngModel)]="options.aliases" rows="1" [core-auto-rows]="options.aliases" |                         {{ 'addon.mod_glossary.aliases' | translate }} | ||||||
|                     aria-labelledby="addon-mod-glossary-aliases-label" name="aliases"> |                     </ion-label> | ||||||
|                 </ion-textarea> |                     <ion-textarea [(ngModel)]="options.aliases" rows="1" [core-auto-rows]="options.aliases" | ||||||
|             </ion-item> |                         aria-labelledby="addon-mod-glossary-aliases-label" name="aliases"> | ||||||
|             <ion-item-divider> |                     </ion-textarea> | ||||||
|                 <ion-label> |                 </ion-item> | ||||||
|                     <h2>{{ 'addon.mod_glossary.attachment' | translate }}</h2> |  | ||||||
|                 </ion-label> |  | ||||||
|             </ion-item-divider> |  | ||||||
|             <core-attachments [files]="attachments" [component]="component" [componentId]="glossary.coursemodule" [allowOffline]="true" |  | ||||||
|                 [courseId]="courseId"> |  | ||||||
|             </core-attachments> |  | ||||||
|             <ng-container *ngIf="glossary.usedynalink"> |  | ||||||
|                 <ion-item-divider> |                 <ion-item-divider> | ||||||
|                     <ion-label> |                     <ion-label> | ||||||
|                         <h2>{{ 'addon.mod_glossary.linking' | translate }}</h2> |                         <h2>{{ 'addon.mod_glossary.attachment' | translate }}</h2> | ||||||
|                     </ion-label> |                     </ion-label> | ||||||
|                 </ion-item-divider> |                 </ion-item-divider> | ||||||
|                 <ion-item class="ion-text-wrap"> |                 <core-attachments [files]="attachments" [component]="component" [componentId]="glossary.coursemodule" [allowOffline]="true" | ||||||
|                     <ion-label>{{ 'addon.mod_glossary.entryusedynalink' | translate }}</ion-label> |                     [courseId]="courseId"> | ||||||
|                     <ion-toggle [(ngModel)]="options.usedynalink" name="usedynalink"></ion-toggle> |                 </core-attachments> | ||||||
|                 </ion-item> |                 <ng-container *ngIf="glossary.usedynalink"> | ||||||
|                 <ion-item class="ion-text-wrap"> |                     <ion-item-divider> | ||||||
|                     <ion-label>{{ 'addon.mod_glossary.casesensitive' | translate }}</ion-label> |                         <ion-label> | ||||||
|                     <ion-toggle [disabled]="!options.usedynalink" [(ngModel)]="options.casesensitive" name="casesensitive"> |                             <h2>{{ 'addon.mod_glossary.linking' | translate }}</h2> | ||||||
|                     </ion-toggle> |                         </ion-label> | ||||||
|                 </ion-item> |                     </ion-item-divider> | ||||||
|                 <ion-item class="ion-text-wrap"> |                     <ion-item class="ion-text-wrap"> | ||||||
|                     <ion-label>{{ 'addon.mod_glossary.fullmatch' | translate }}</ion-label> |                         <ion-label>{{ 'addon.mod_glossary.entryusedynalink' | translate }}</ion-label> | ||||||
|                     <ion-toggle [disabled]="!options.usedynalink" [(ngModel)]="options.fullmatch" name="fullmatch"></ion-toggle> |                         <ion-toggle [(ngModel)]="options.usedynalink" name="usedynalink"></ion-toggle> | ||||||
|                 </ion-item> |                     </ion-item> | ||||||
|             </ng-container> |                     <ion-item class="ion-text-wrap"> | ||||||
|             <ion-button class="ion-margin" expand="block" [disabled]="!entry.concept || !entry.definition" (click)="save()"> |                         <ion-label>{{ 'addon.mod_glossary.casesensitive' | translate }}</ion-label> | ||||||
|                 {{ 'core.save' | translate }} |                         <ion-toggle [disabled]="!options.usedynalink" [(ngModel)]="options.casesensitive" name="casesensitive"> | ||||||
|             </ion-button> |                         </ion-toggle> | ||||||
|         </form> |                     </ion-item> | ||||||
|     </core-loading> |                     <ion-item class="ion-text-wrap"> | ||||||
|  |                         <ion-label>{{ 'addon.mod_glossary.fullmatch' | translate }}</ion-label> | ||||||
|  |                         <ion-toggle [disabled]="!options.usedynalink" [(ngModel)]="options.fullmatch" name="fullmatch"></ion-toggle> | ||||||
|  |                     </ion-item> | ||||||
|  |                 </ng-container> | ||||||
|  |                 <ion-button class="ion-margin" expand="block" [disabled]="!entry.concept || !entry.definition" (click)="save()"> | ||||||
|  |                     {{ 'core.save' | translate }} | ||||||
|  |                 </ion-button> | ||||||
|  |             </form> | ||||||
|  |         </core-loading> | ||||||
|  |     </core-swipe-navigation> | ||||||
| </ion-content> | </ion-content> | ||||||
|  | |||||||
| @ -12,9 +12,11 @@ | |||||||
| // See the License for the specific language governing permissions and
 | // See the License for the specific language governing permissions and
 | ||||||
| // limitations under the License.
 | // limitations under the License.
 | ||||||
| 
 | 
 | ||||||
| import { Component, OnInit, ViewChild, ElementRef, Optional } from '@angular/core'; | import { Component, OnInit, ViewChild, ElementRef, Optional, OnDestroy } from '@angular/core'; | ||||||
| import { FormControl } from '@angular/forms'; | import { FormControl } from '@angular/forms'; | ||||||
|  | import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||||
| import { CoreError } from '@classes/errors/error'; | import { CoreError } from '@classes/errors/error'; | ||||||
|  | import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||||
| import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; | import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; | ||||||
| import { CanLeave } from '@guards/can-leave'; | import { CanLeave } from '@guards/can-leave'; | ||||||
| @ -26,6 +28,8 @@ import { CoreTextUtils } from '@services/utils/text'; | |||||||
| import { Translate } from '@singletons'; | import { Translate } from '@singletons'; | ||||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||||
| import { CoreForms } from '@singletons/form'; | import { CoreForms } from '@singletons/form'; | ||||||
|  | import { AddonModGlossaryEntriesSource } from '../../classes/glossary-entries-source'; | ||||||
|  | import { AddonModGlossaryEntriesSwipeManager } from '../../classes/glossary-entries-swipe-manager'; | ||||||
| import { | import { | ||||||
|     AddonModGlossary, |     AddonModGlossary, | ||||||
|     AddonModGlossaryCategory, |     AddonModGlossaryCategory, | ||||||
| @ -45,7 +49,7 @@ import { AddonModGlossaryOffline } from '../../services/glossary-offline'; | |||||||
|     selector: 'page-addon-mod-glossary-edit', |     selector: 'page-addon-mod-glossary-edit', | ||||||
|     templateUrl: 'edit.html', |     templateUrl: 'edit.html', | ||||||
| }) | }) | ||||||
| export class AddonModGlossaryEditPage implements OnInit, CanLeave { | export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave { | ||||||
| 
 | 
 | ||||||
|     @ViewChild('editFormEl') formElement?: ElementRef; |     @ViewChild('editFormEl') formElement?: ElementRef; | ||||||
| 
 | 
 | ||||||
| @ -64,6 +68,8 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
|         timecreated: 0, |         timecreated: 0, | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|  |     entries?: AddonModGlossaryEditEntriesSwipeManager; | ||||||
|  | 
 | ||||||
|     options = { |     options = { | ||||||
|         categories: <string[]> [], |         categories: <string[]> [], | ||||||
|         aliases: '', |         aliases: '', | ||||||
| @ -80,18 +86,30 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
|     protected originalData?: AddonModGlossaryNewEntryWithFiles; |     protected originalData?: AddonModGlossaryNewEntryWithFiles; | ||||||
|     protected saved = false; |     protected saved = false; | ||||||
| 
 | 
 | ||||||
|     constructor(@Optional() protected splitView: CoreSplitViewComponent) {} |     constructor(protected route: ActivatedRoute, @Optional() protected splitView: CoreSplitViewComponent) {} | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Component being initialized. |      * Component being initialized. | ||||||
|      */ |      */ | ||||||
|     ngOnInit(): void { |     async ngOnInit(): Promise<void> { | ||||||
|         try { |         try { | ||||||
|  |             const routeData = this.route.snapshot.data; | ||||||
|             this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); |             this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); | ||||||
|             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); |             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||||
|             this.timecreated = CoreNavigator.getRequiredRouteNumberParam('timecreated'); |             this.timecreated = CoreNavigator.getRequiredRouteNumberParam('timecreated'); | ||||||
|             this.concept = CoreNavigator.getRouteParam<string>('concept') || ''; |             this.concept = CoreNavigator.getRouteParam<string>('concept') || ''; | ||||||
|             this.editorExtraParams.timecreated = this.timecreated; |             this.editorExtraParams.timecreated = this.timecreated; | ||||||
|  | 
 | ||||||
|  |             if (this.timecreated !== 0 && (routeData.swipeEnabled ?? true)) { | ||||||
|  |                 const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||||
|  |                     AddonModGlossaryEntriesSource, | ||||||
|  |                     [this.courseId, this.cmId, routeData.glossaryPathPrefix ?? ''], | ||||||
|  |                 ); | ||||||
|  | 
 | ||||||
|  |                 this.entries = new AddonModGlossaryEditEntriesSwipeManager(source); | ||||||
|  | 
 | ||||||
|  |                 await this.entries.start(); | ||||||
|  |             } | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             CoreDomUtils.showErrorModal(error); |             CoreDomUtils.showErrorModal(error); | ||||||
| 
 | 
 | ||||||
| @ -103,6 +121,13 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
|         this.fetchData(); |         this.fetchData(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     ngOnDestroy(): void { | ||||||
|  |         this.entries?.destroy(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * Fetch required data. |      * Fetch required data. | ||||||
|      * |      * | ||||||
| @ -134,7 +159,11 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
|      * @return Promise resolved when done. |      * @return Promise resolved when done. | ||||||
|      */ |      */ | ||||||
|     protected async loadOfflineData(): Promise<void> { |     protected async loadOfflineData(): Promise<void> { | ||||||
|         const entry = await AddonModGlossaryOffline.getNewEntry(this.glossary!.id, this.concept, this.timecreated); |         if (!this.glossary) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const entry = await AddonModGlossaryOffline.getNewEntry(this.glossary.id, this.concept, this.timecreated); | ||||||
| 
 | 
 | ||||||
|         this.entry.concept = entry.concept || ''; |         this.entry.concept = entry.concept || ''; | ||||||
|         this.entry.definition = entry.definition || ''; |         this.entry.definition = entry.definition || ''; | ||||||
| @ -159,7 +188,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
| 
 | 
 | ||||||
|         // Treat offline attachments if any.
 |         // Treat offline attachments if any.
 | ||||||
|         if (entry.attachments?.offline) { |         if (entry.attachments?.offline) { | ||||||
|             this.attachments = await AddonModGlossaryHelper.getStoredFiles(this.glossary!.id, entry.concept, entry.timecreated); |             this.attachments = await AddonModGlossaryHelper.getStoredFiles(this.glossary.id, entry.concept, entry.timecreated); | ||||||
| 
 | 
 | ||||||
|             this.originalData.files = this.attachments.slice(); |             this.originalData.files = this.attachments.slice(); | ||||||
|         } |         } | ||||||
| @ -236,6 +265,10 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
|         definition = CoreTextUtils.formatHtmlLines(definition); |         definition = CoreTextUtils.formatHtmlLines(definition); | ||||||
| 
 | 
 | ||||||
|         try { |         try { | ||||||
|  |             if (!this.glossary) { | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|             // Upload attachments first if any.
 |             // Upload attachments first if any.
 | ||||||
|             const { saveOffline, attachmentsResult } = await this.uploadAttachments(timecreated); |             const { saveOffline, attachmentsResult } = await this.uploadAttachments(timecreated); | ||||||
| 
 | 
 | ||||||
| @ -244,7 +277,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
|                 categories: this.options.categories.join(','), |                 categories: this.options.categories.join(','), | ||||||
|             }; |             }; | ||||||
| 
 | 
 | ||||||
|             if (this.glossary!.usedynalink) { |             if (this.glossary.usedynalink) { | ||||||
|                 options.usedynalink = this.options.usedynalink ? 1 : 0; |                 options.usedynalink = this.options.usedynalink ? 1 : 0; | ||||||
|                 if (this.options.usedynalink) { |                 if (this.options.usedynalink) { | ||||||
|                     options.casesensitive = this.options.casesensitive ? 1 : 0; |                     options.casesensitive = this.options.casesensitive ? 1 : 0; | ||||||
| @ -253,9 +286,9 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             if (saveOffline) { |             if (saveOffline) { | ||||||
|                 if (this.entry && !this.glossary!.allowduplicatedentries) { |                 if (this.entry && !this.glossary.allowduplicatedentries) { | ||||||
|                     // Check if the entry is duplicated in online or offline mode.
 |                     // Check if the entry is duplicated in online or offline mode.
 | ||||||
|                     const isUsed = await AddonModGlossary.isConceptUsed(this.glossary!.id, this.entry.concept, { |                     const isUsed = await AddonModGlossary.isConceptUsed(this.glossary.id, this.entry.concept, { | ||||||
|                         timeCreated: this.entry.timecreated, |                         timeCreated: this.entry.timecreated, | ||||||
|                         cmId: this.cmId, |                         cmId: this.cmId, | ||||||
|                     }); |                     }); | ||||||
| @ -268,7 +301,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
| 
 | 
 | ||||||
|                 // Save entry in offline.
 |                 // Save entry in offline.
 | ||||||
|                 await AddonModGlossaryOffline.addNewEntry( |                 await AddonModGlossaryOffline.addNewEntry( | ||||||
|                     this.glossary!.id, |                     this.glossary.id, | ||||||
|                     this.entry.concept, |                     this.entry.concept, | ||||||
|                     definition, |                     definition, | ||||||
|                     this.courseId, |                     this.courseId, | ||||||
| @ -283,7 +316,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
|                 // Try to send it to server.
 |                 // Try to send it to server.
 | ||||||
|                 // Don't allow offline if there are attachments since they were uploaded fine.
 |                 // Don't allow offline if there are attachments since they were uploaded fine.
 | ||||||
|                 await AddonModGlossary.addEntry( |                 await AddonModGlossary.addEntry( | ||||||
|                     this.glossary!.id, |                     this.glossary.id, | ||||||
|                     this.entry.concept, |                     this.entry.concept, | ||||||
|                     definition, |                     definition, | ||||||
|                     this.courseId, |                     this.courseId, | ||||||
| @ -293,7 +326,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
|                         timeCreated: timecreated, |                         timeCreated: timecreated, | ||||||
|                         discardEntry: this.entry, |                         discardEntry: this.entry, | ||||||
|                         allowOffline: !this.attachments.length, |                         allowOffline: !this.attachments.length, | ||||||
|                         checkDuplicates: !this.glossary!.allowduplicatedentries, |                         checkDuplicates: !this.glossary.allowduplicatedentries, | ||||||
|                     }, |                     }, | ||||||
|                 ); |                 ); | ||||||
|             } |             } | ||||||
| @ -303,12 +336,12 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
| 
 | 
 | ||||||
|             if (entryId) { |             if (entryId) { | ||||||
|                 // Data sent to server, delete stored files (if any).
 |                 // Data sent to server, delete stored files (if any).
 | ||||||
|                 AddonModGlossaryHelper.deleteStoredFiles(this.glossary!.id, this.entry.concept, timecreated); |                 AddonModGlossaryHelper.deleteStoredFiles(this.glossary.id, this.entry.concept, timecreated); | ||||||
|                 CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'glossary' }); |                 CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'glossary' }); | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             CoreEvents.trigger(AddonModGlossaryProvider.ADD_ENTRY_EVENT, { |             CoreEvents.trigger(AddonModGlossaryProvider.ADD_ENTRY_EVENT, { | ||||||
|                 glossaryId: this.glossary!.id, |                 glossaryId: this.glossary.id, | ||||||
|                 entryId: entryId, |                 entryId: entryId, | ||||||
|             }, CoreSites.getCurrentSiteId()); |             }, CoreSites.getCurrentSiteId()); | ||||||
| 
 | 
 | ||||||
| @ -342,7 +375,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
|     protected async uploadAttachments( |     protected async uploadAttachments( | ||||||
|         timecreated: number, |         timecreated: number, | ||||||
|     ): Promise<{saveOffline: boolean; attachmentsResult?: number | CoreFileUploaderStoreFilesResult}> { |     ): Promise<{saveOffline: boolean; attachmentsResult?: number | CoreFileUploaderStoreFilesResult}> { | ||||||
|         if (!this.attachments.length) { |         if (!this.attachments.length || !this.glossary) { | ||||||
|             return { |             return { | ||||||
|                 saveOffline: false, |                 saveOffline: false, | ||||||
|             }; |             }; | ||||||
| @ -352,7 +385,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
|             const attachmentsResult = await CoreFileUploader.uploadOrReuploadFiles( |             const attachmentsResult = await CoreFileUploader.uploadOrReuploadFiles( | ||||||
|                 this.attachments, |                 this.attachments, | ||||||
|                 AddonModGlossaryProvider.COMPONENT, |                 AddonModGlossaryProvider.COMPONENT, | ||||||
|                 this.glossary!.id, |                 this.glossary.id, | ||||||
|             ); |             ); | ||||||
| 
 | 
 | ||||||
|             return { |             return { | ||||||
| @ -362,7 +395,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
|         } catch { |         } catch { | ||||||
|             // Cannot upload them in online, save them in offline.
 |             // Cannot upload them in online, save them in offline.
 | ||||||
|             const attachmentsResult = await AddonModGlossaryHelper.storeFiles( |             const attachmentsResult = await AddonModGlossaryHelper.storeFiles( | ||||||
|                 this.glossary!.id, |                 this.glossary.id, | ||||||
|                 this.entry.concept, |                 this.entry.concept, | ||||||
|                 timecreated, |                 timecreated, | ||||||
|                 this.attachments, |                 this.attachments, | ||||||
| @ -387,3 +420,17 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Helper to manage swiping within a collection of glossary entries. | ||||||
|  |  */ | ||||||
|  | class AddonModGlossaryEditEntriesSwipeManager extends AddonModGlossaryEntriesSwipeManager { | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||||
|  |         return `${this.getSource().GLOSSARY_PATH_PREFIX}edit/${route.params.timecreated}`; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | |||||||
| @ -12,73 +12,75 @@ | |||||||
|     </ion-toolbar> |     </ion-toolbar> | ||||||
| </ion-header> | </ion-header> | ||||||
| <ion-content> | <ion-content> | ||||||
|     <ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="doRefresh($event.target)"> |     <core-swipe-navigation [manager]="entries"> | ||||||
|         <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> |         <ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="doRefresh($event.target)"> | ||||||
|     </ion-refresher> |             <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||||
|  |         </ion-refresher> | ||||||
| 
 | 
 | ||||||
|     <core-loading [hideUntil]="loaded"> |         <core-loading [hideUntil]="loaded"> | ||||||
|         <ng-container *ngIf="entry && loaded"> |             <ng-container *ngIf="entry && loaded"> | ||||||
|             <ion-item class="ion-text-wrap" *ngIf="showAuthor"> |                 <ion-item class="ion-text-wrap" *ngIf="showAuthor"> | ||||||
|                 <core-user-avatar [user]="entry" slot="start"></core-user-avatar> |                     <core-user-avatar [user]="entry" slot="start"></core-user-avatar> | ||||||
|                 <ion-label> |                     <ion-label> | ||||||
|                     <h2> |                         <h2> | ||||||
|                         <core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId" |                             <core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId" | ||||||
|                             [courseId]="courseId"> |                                 [courseId]="courseId"> | ||||||
|  |                             </core-format-text> | ||||||
|  |                         </h2> | ||||||
|  |                         <p>{{ entry.userfullname }}</p> | ||||||
|  |                     </ion-label> | ||||||
|  |                     <ion-note slot="end" *ngIf="showDate">{{ entry.timemodified | coreDateDayOrTime }}</ion-note> | ||||||
|  |                 </ion-item> | ||||||
|  |                 <ion-item class="ion-text-wrap" *ngIf="!showAuthor"> | ||||||
|  |                     <ion-label> | ||||||
|  |                         <p class="item-heading"> | ||||||
|  |                             <core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId"> | ||||||
|  |                             </core-format-text> | ||||||
|  |                         </p> | ||||||
|  |                     </ion-label> | ||||||
|  |                     <ion-note slot="end" *ngIf="showDate">{{ entry.timemodified | coreDateDayOrTime }}</ion-note> | ||||||
|  |                 </ion-item> | ||||||
|  |                 <ion-item class="ion-text-wrap"> | ||||||
|  |                     <ion-label> | ||||||
|  |                         <core-format-text [component]="component" [componentId]="componentId" [text]="entry.definition" | ||||||
|  |                             contextLevel="module" [contextInstanceId]="componentId" [courseId]="courseId"> | ||||||
|                         </core-format-text> |                         </core-format-text> | ||||||
|                     </h2> |                     </ion-label> | ||||||
|                     <p>{{ entry.userfullname }}</p> |                 </ion-item> | ||||||
|                 </ion-label> |                 <div *ngIf="entry.attachment" lines="none"> | ||||||
|                 <ion-note slot="end" *ngIf="showDate">{{ entry.timemodified | coreDateDayOrTime }}</ion-note> |                     <core-file *ngFor="let file of entry.attachments" [file]="file" [component]="component" [componentId]="componentId"> | ||||||
|             </ion-item> |                     </core-file> | ||||||
|             <ion-item class="ion-text-wrap" *ngIf="!showAuthor"> |                 </div> | ||||||
|                 <ion-label> |                 <ion-item class="ion-text-wrap" *ngIf="tagsEnabled && entry && entry.tags && entry.tags.length > 0"> | ||||||
|                     <p class="item-heading"> |                     <ion-label> | ||||||
|                         <core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId"> |                         <div slot="start">{{ 'core.tag.tags' | translate }}:</div> | ||||||
|                         </core-format-text> |                         <core-tag-list [tags]="entry.tags"></core-tag-list> | ||||||
|                     </p> |                     </ion-label> | ||||||
|                 </ion-label> |                 </ion-item> | ||||||
|                 <ion-note slot="end" *ngIf="showDate">{{ entry.timemodified | coreDateDayOrTime }}</ion-note> |                 <ion-item class="ion-text-wrap" *ngIf="!entry.approved"> | ||||||
|             </ion-item> |                     <ion-label> | ||||||
|             <ion-item class="ion-text-wrap"> |                         <p><em>{{ 'addon.mod_glossary.entrypendingapproval' | translate }}</em></p> | ||||||
|                 <ion-label> |                     </ion-label> | ||||||
|                     <core-format-text [component]="component" [componentId]="componentId" [text]="entry.definition" contextLevel="module" |                 </ion-item> | ||||||
|                         [contextInstanceId]="componentId" [courseId]="courseId"> |                 <core-comments *ngIf="glossary && glossary.allowcomments && entry && entry.id > 0 && commentsEnabled" contextLevel="module" | ||||||
|                     </core-format-text> |                     [instanceId]="glossary.coursemodule" component="mod_glossary" [itemId]="entry.id" area="glossary_entry" | ||||||
|                 </ion-label> |                     [courseId]="glossary.course" [showItem]="true"> | ||||||
|             </ion-item> |                 </core-comments> | ||||||
|             <div *ngIf="entry.attachment" lines="none"> |                 <core-rating-rate *ngIf="glossary && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module" | ||||||
|                 <core-file *ngFor="let file of entry.attachments" [file]="file" [component]="component" [componentId]="componentId"> |                     [instanceId]="glossary.coursemodule" [itemId]="entry.id" [itemSetId]="0" [courseId]="glossary.course" | ||||||
|                 </core-file> |                     [aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale" [userId]="entry.userid" (onUpdate)="ratingUpdated()"> | ||||||
|             </div> |                 </core-rating-rate> | ||||||
|             <ion-item class="ion-text-wrap" *ngIf="tagsEnabled && entry && entry.tags && entry.tags.length > 0"> |                 <core-rating-aggregate *ngIf="glossary && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module" | ||||||
|                 <ion-label> |                     [instanceId]="glossary.coursemodule" [itemId]="entry.id" [courseId]="glossary.course" | ||||||
|                     <div slot="start">{{ 'core.tag.tags' | translate }}:</div> |                     [aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale"> | ||||||
|                     <core-tag-list [tags]="entry.tags"></core-tag-list> |                 </core-rating-aggregate> | ||||||
|                 </ion-label> |             </ng-container> | ||||||
|             </ion-item> |  | ||||||
|             <ion-item class="ion-text-wrap" *ngIf="!entry.approved"> |  | ||||||
|                 <ion-label> |  | ||||||
|                     <p><em>{{ 'addon.mod_glossary.entrypendingapproval' | translate }}</em></p> |  | ||||||
|                 </ion-label> |  | ||||||
|             </ion-item> |  | ||||||
|             <core-comments *ngIf="glossary && glossary.allowcomments && entry && entry.id > 0 && commentsEnabled" contextLevel="module" |  | ||||||
|                 [instanceId]="glossary.coursemodule" component="mod_glossary" [itemId]="entry.id" area="glossary_entry" |  | ||||||
|                 [courseId]="glossary.course" [showItem]="true"> |  | ||||||
|             </core-comments> |  | ||||||
|             <core-rating-rate *ngIf="glossary && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module" |  | ||||||
|                 [instanceId]="glossary.coursemodule" [itemId]="entry.id" [itemSetId]="0" [courseId]="glossary.course" |  | ||||||
|                 [aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale" [userId]="entry.userid" (onUpdate)="ratingUpdated()"> |  | ||||||
|             </core-rating-rate> |  | ||||||
|             <core-rating-aggregate *ngIf="glossary && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module" |  | ||||||
|                 [instanceId]="glossary.coursemodule" [itemId]="entry.id" [courseId]="glossary.course" [aggregateMethod]="glossary.assessed" |  | ||||||
|                 [scaleId]="glossary.scale"> |  | ||||||
|             </core-rating-aggregate> |  | ||||||
|         </ng-container> |  | ||||||
| 
 | 
 | ||||||
|         <ion-card *ngIf="!entry" class="core-warning-card"> |             <ion-card *ngIf="!entry" class="core-warning-card"> | ||||||
|             <ion-item> |                 <ion-item> | ||||||
|                 <ion-label>{{ 'addon.mod_glossary.errorloadingentry' | translate }}</ion-label> |                     <ion-label>{{ 'addon.mod_glossary.errorloadingentry' | translate }}</ion-label> | ||||||
|             </ion-item> |                 </ion-item> | ||||||
|         </ion-card> |             </ion-card> | ||||||
|     </core-loading> |         </core-loading> | ||||||
|  |     </core-swipe-navigation> | ||||||
| </ion-content> | </ion-content> | ||||||
|  | |||||||
| @ -12,7 +12,9 @@ | |||||||
| // See the License for the specific language governing permissions and
 | // See the License for the specific language governing permissions and
 | ||||||
| // limitations under the License.
 | // limitations under the License.
 | ||||||
| 
 | 
 | ||||||
| import { Component, OnInit, ViewChild } from '@angular/core'; | import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; | ||||||
|  | import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||||
|  | import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||||
| import { CoreCommentsCommentsComponent } from '@features/comments/components/comments/comments'; | import { CoreCommentsCommentsComponent } from '@features/comments/components/comments/comments'; | ||||||
| import { CoreComments } from '@features/comments/services/comments'; | import { CoreComments } from '@features/comments/services/comments'; | ||||||
| import { CoreRatingInfo } from '@features/rating/services/rating'; | import { CoreRatingInfo } from '@features/rating/services/rating'; | ||||||
| @ -21,6 +23,8 @@ import { IonRefresher } from '@ionic/angular'; | |||||||
| import { CoreNavigator } from '@services/navigator'; | import { CoreNavigator } from '@services/navigator'; | ||||||
| import { CoreDomUtils } from '@services/utils/dom'; | import { CoreDomUtils } from '@services/utils/dom'; | ||||||
| import { CoreUtils } from '@services/utils/utils'; | import { CoreUtils } from '@services/utils/utils'; | ||||||
|  | import { AddonModGlossaryEntriesSource } from '../../classes/glossary-entries-source'; | ||||||
|  | import { AddonModGlossaryEntriesSwipeManager } from '../../classes/glossary-entries-swipe-manager'; | ||||||
| import { | import { | ||||||
|     AddonModGlossary, |     AddonModGlossary, | ||||||
|     AddonModGlossaryEntry, |     AddonModGlossaryEntry, | ||||||
| @ -35,13 +39,14 @@ import { | |||||||
|     selector: 'page-addon-mod-glossary-entry', |     selector: 'page-addon-mod-glossary-entry', | ||||||
|     templateUrl: 'entry.html', |     templateUrl: 'entry.html', | ||||||
| }) | }) | ||||||
| export class AddonModGlossaryEntryPage implements OnInit { | export class AddonModGlossaryEntryPage implements OnInit, OnDestroy { | ||||||
| 
 | 
 | ||||||
|     @ViewChild(CoreCommentsCommentsComponent) comments?: CoreCommentsCommentsComponent; |     @ViewChild(CoreCommentsCommentsComponent) comments?: CoreCommentsCommentsComponent; | ||||||
| 
 | 
 | ||||||
|     component = AddonModGlossaryProvider.COMPONENT; |     component = AddonModGlossaryProvider.COMPONENT; | ||||||
|     componentId?: number; |     componentId?: number; | ||||||
|     entry?: AddonModGlossaryEntry; |     entry?: AddonModGlossaryEntry; | ||||||
|  |     entries?: AddonModGlossaryEntryEntriesSwipeManager; | ||||||
|     glossary?: AddonModGlossaryGlossary; |     glossary?: AddonModGlossaryGlossary; | ||||||
|     loaded = false; |     loaded = false; | ||||||
|     showAuthor = false; |     showAuthor = false; | ||||||
| @ -53,15 +58,30 @@ export class AddonModGlossaryEntryPage implements OnInit { | |||||||
| 
 | 
 | ||||||
|     protected entryId!: number; |     protected entryId!: number; | ||||||
| 
 | 
 | ||||||
|  |     constructor(protected route: ActivatedRoute) {} | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     async ngOnInit(): Promise<void> { |     async ngOnInit(): Promise<void> { | ||||||
|         try { |         try { | ||||||
|  |             const routeData = this.route.snapshot.data; | ||||||
|             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); |             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||||
|             this.entryId = CoreNavigator.getRequiredRouteNumberParam('entryId'); |             this.entryId = CoreNavigator.getRequiredRouteNumberParam('entryId'); | ||||||
|             this.tagsEnabled = CoreTag.areTagsAvailableInSite(); |             this.tagsEnabled = CoreTag.areTagsAvailableInSite(); | ||||||
|             this.commentsEnabled = !CoreComments.areCommentsDisabledInSite(); |             this.commentsEnabled = !CoreComments.areCommentsDisabledInSite(); | ||||||
|  | 
 | ||||||
|  |             if (routeData.swipeEnabled ?? true) { | ||||||
|  |                 const cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); | ||||||
|  |                 const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||||
|  |                     AddonModGlossaryEntriesSource, | ||||||
|  |                     [this.courseId, cmId, routeData.glossaryPathPrefix ?? ''], | ||||||
|  |                 ); | ||||||
|  | 
 | ||||||
|  |                 this.entries = new AddonModGlossaryEntryEntriesSwipeManager(source); | ||||||
|  | 
 | ||||||
|  |                 await this.entries.start(); | ||||||
|  |             } | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             CoreDomUtils.showErrorModal(error); |             CoreDomUtils.showErrorModal(error); | ||||||
| 
 | 
 | ||||||
| @ -73,16 +93,23 @@ export class AddonModGlossaryEntryPage implements OnInit { | |||||||
|         try { |         try { | ||||||
|             await this.fetchEntry(); |             await this.fetchEntry(); | ||||||
| 
 | 
 | ||||||
|             if (!this.glossary) { |             if (!this.glossary || !this.componentId) { | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             await CoreUtils.ignoreErrors(AddonModGlossary.logEntryView(this.entryId, this.componentId!, this.glossary.name)); |             await CoreUtils.ignoreErrors(AddonModGlossary.logEntryView(this.entryId, this.componentId, this.glossary.name)); | ||||||
|         } finally { |         } finally { | ||||||
|             this.loaded = true; |             this.loaded = true; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     ngOnDestroy(): void { | ||||||
|  |         this.entries?.destroy(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * Refresh the data. |      * Refresh the data. | ||||||
|      * |      * | ||||||
| @ -152,3 +179,17 @@ export class AddonModGlossaryEntryPage implements OnInit { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Helper to manage swiping within a collection of glossary entries. | ||||||
|  |  */ | ||||||
|  | class AddonModGlossaryEntryEntriesSwipeManager extends AddonModGlossaryEntriesSwipeManager { | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||||
|  |         return `${this.getSource().GLOSSARY_PATH_PREFIX}entry/${route.params.entryId}`; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | |||||||
| @ -12,12 +12,14 @@ | |||||||
| // See the License for the specific language governing permissions and
 | // See the License for the specific language governing permissions and
 | ||||||
| // limitations under the License.
 | // limitations under the License.
 | ||||||
| 
 | 
 | ||||||
|  | import { Params } from '@angular/router'; | ||||||
|  | 
 | ||||||
| /** | /** | ||||||
|  * Updates listener. |  * Updates listener. | ||||||
|  */ |  */ | ||||||
| export interface CoreItemsListSourceListener<Item> { | export interface CoreItemsListSourceListener<Item> { | ||||||
|     onItemsUpdated(items: Item[], hasMoreItems: boolean): void; |     onItemsUpdated?(items: Item[], hasMoreItems: boolean): void; | ||||||
|     onReset(): void; |     onReset?(): void; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
| @ -35,9 +37,10 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | |||||||
|         return args.map(argument => String(argument)).join('-'); |         return args.map(argument => String(argument)).join('-'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     private items: Item[] | null = null; |     protected items: Item[] | null = null; | ||||||
|     private hasMoreItems = true; |     protected hasMoreItems = true; | ||||||
|     private listeners: CoreItemsListSourceListener<Item>[] = []; |     protected listeners: CoreItemsListSourceListener<Item>[] = []; | ||||||
|  |     protected dirty = false; | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Check whether any page has been loaded. |      * Check whether any page has been loaded. | ||||||
| @ -57,6 +60,17 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | |||||||
|         return !this.hasMoreItems; |         return !this.hasMoreItems; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * Set whether the source as dirty. | ||||||
|  |      * | ||||||
|  |      * When a source is dirty, the next load request will reload items from the beginning. | ||||||
|  |      * | ||||||
|  |      * @param dirty Whether source should be marked as dirty or not. | ||||||
|  |      */ | ||||||
|  |     setDirty(dirty: boolean): void { | ||||||
|  |         this.dirty = dirty; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * Get collection items. |      * Get collection items. | ||||||
|      * |      * | ||||||
| @ -76,7 +90,12 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | |||||||
|             return 0; |             return 0; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         return Math.ceil(this.items.length / this.getPageLength()); |         const pageLength = this.getPageLength(); | ||||||
|  |         if (pageLength === null) { | ||||||
|  |             return 1; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return Math.ceil(this.items.length / pageLength); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -85,8 +104,9 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | |||||||
|     reset(): void { |     reset(): void { | ||||||
|         this.items = null; |         this.items = null; | ||||||
|         this.hasMoreItems = true; |         this.hasMoreItems = true; | ||||||
|  |         this.dirty = false; | ||||||
| 
 | 
 | ||||||
|         this.listeners.forEach(listener => listener.onReset()); |         this.listeners.forEach(listener => listener.onReset?.call(listener)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -122,36 +142,67 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | |||||||
|     async reload(): Promise<void> { |     async reload(): Promise<void> { | ||||||
|         const { items, hasMoreItems } = await this.loadPageItems(0); |         const { items, hasMoreItems } = await this.loadPageItems(0); | ||||||
| 
 | 
 | ||||||
|         this.setItems(items, hasMoreItems); |         this.dirty = false; | ||||||
|  |         this.setItems(items, hasMoreItems ?? false); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Load items for the next page, if any. |      * Load more items, if any. | ||||||
|      */ |      */ | ||||||
|     async loadNextPage(): Promise<void> { |     async load(): Promise<void> { | ||||||
|  |         if (this.dirty) { | ||||||
|  |             const { items, hasMoreItems } = await this.loadPageItems(0); | ||||||
|  | 
 | ||||||
|  |             this.dirty = false; | ||||||
|  |             this.setItems(items, hasMoreItems ?? false); | ||||||
|  | 
 | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|         if (!this.hasMoreItems) { |         if (!this.hasMoreItems) { | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         const { items, hasMoreItems } = await this.loadPageItems(this.getPagesLoaded()); |         const { items, hasMoreItems } = await this.loadPageItems(this.getPagesLoaded()); | ||||||
| 
 | 
 | ||||||
|         this.setItems((this.items ?? []).concat(items), hasMoreItems); |         this.setItems((this.items ?? []).concat(items), hasMoreItems ?? false); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * Get the query parameters to use when navigating to an item page. | ||||||
|  |      * | ||||||
|  |      * @param item Item. | ||||||
|  |      * @return Query parameters to use when navigating to the item page. | ||||||
|  |      */ | ||||||
|  |     // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||||
|  |     getItemQueryParams(item: Item): Params { | ||||||
|  |         return {}; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Get the path to use when navigating to an item page. | ||||||
|  |      * | ||||||
|  |      * @param item Item. | ||||||
|  |      * @return Path to use when navigating to the item page. | ||||||
|  |      */ | ||||||
|  |     abstract getItemPath(item: Item): string; | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * Load page items. |      * Load page items. | ||||||
|      * |      * | ||||||
|      * @param page Page number (starting at 0). |      * @param page Page number (starting at 0). | ||||||
|      * @return Page items data. |      * @return Page items data. | ||||||
|      */ |      */ | ||||||
|     protected abstract loadPageItems(page: number): Promise<{ items: Item[]; hasMoreItems: boolean }>; |     protected abstract loadPageItems(page: number): Promise<{ items: Item[]; hasMoreItems?: boolean }>; | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Get the length of each page in the collection. |      * Get the length of each page in the collection. | ||||||
|      * |      * | ||||||
|      * @return Page length. |      * @return Page length; null for collections that don't support pagination. | ||||||
|      */ |      */ | ||||||
|     protected abstract getPageLength(): number; |     protected getPageLength(): number | null { | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Update the collection items. |      * Update the collection items. | ||||||
| @ -163,7 +214,7 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | |||||||
|         this.items = items; |         this.items = items; | ||||||
|         this.hasMoreItems = hasMoreItems; |         this.hasMoreItems = hasMoreItems; | ||||||
| 
 | 
 | ||||||
|         this.listeners.forEach(listener => listener.onItemsUpdated(items, hasMoreItems)); |         this.listeners.forEach(listener => listener.onItemsUpdated?.call(listener, items, hasMoreItems)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| } | } | ||||||
|  | |||||||
| @ -18,6 +18,7 @@ type SourceConstructor<T extends CoreItemsManagerSource = CoreItemsManagerSource | |||||||
|     getSourceId(...args: unknown[]): string; |     getSourceId(...args: unknown[]): string; | ||||||
|     new (...args: unknown[]): T; |     new (...args: unknown[]): T; | ||||||
| }; | }; | ||||||
|  | type SourceConstuctorInstance<T> = T extends { new(...args: unknown[]): infer P } ? P : never; | ||||||
| type InstanceTracking = { instance: CoreItemsManagerSource; references: unknown[] }; | type InstanceTracking = { instance: CoreItemsManagerSource; references: unknown[] }; | ||||||
| type Instances = Record<string, InstanceTracking>; | type Instances = Record<string, InstanceTracking>; | ||||||
| 
 | 
 | ||||||
| @ -36,14 +37,14 @@ export class CoreItemsManagerSourcesTracker { | |||||||
|      * @param constructorArguments Arguments to create a new instance, used to find out if an instance already exists. |      * @param constructorArguments Arguments to create a new instance, used to find out if an instance already exists. | ||||||
|      * @returns Source. |      * @returns Source. | ||||||
|      */ |      */ | ||||||
|     static getOrCreateSource<T extends CoreItemsManagerSource>( |     static getOrCreateSource<T extends CoreItemsManagerSource, C extends SourceConstructor<T>>( | ||||||
|         constructor: SourceConstructor<T>, |         constructor: C, | ||||||
|         constructorArguments: ConstructorParameters<SourceConstructor<T>>, |         constructorArguments: ConstructorParameters<C>, | ||||||
|     ): T  { |     ): SourceConstuctorInstance<C> { | ||||||
|         const id = constructor.getSourceId(...constructorArguments); |         const id = constructor.getSourceId(...constructorArguments); | ||||||
|         const constructorInstances = this.getConstructorInstances(constructor); |         const constructorInstances = this.getConstructorInstances(constructor); | ||||||
| 
 | 
 | ||||||
|         return constructorInstances[id]?.instance as T |         return constructorInstances[id]?.instance as SourceConstuctorInstance<C> | ||||||
|             ?? this.createInstance(id, constructor, constructorArguments); |             ?? this.createInstance(id, constructor, constructorArguments); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -57,7 +58,7 @@ export class CoreItemsManagerSourcesTracker { | |||||||
|         const constructorInstances = this.getConstructorInstances(source.constructor as SourceConstructor); |         const constructorInstances = this.getConstructorInstances(source.constructor as SourceConstructor); | ||||||
|         const instanceId = this.instanceIds.get(source); |         const instanceId = this.instanceIds.get(source); | ||||||
| 
 | 
 | ||||||
|         if (!instanceId) { |         if (instanceId === undefined) { | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
| @ -82,7 +83,7 @@ export class CoreItemsManagerSourcesTracker { | |||||||
|         const instanceId = this.instanceIds.get(source); |         const instanceId = this.instanceIds.get(source); | ||||||
|         const index = constructorInstances?.[instanceId ?? '']?.references.indexOf(reference) ?? -1; |         const index = constructorInstances?.[instanceId ?? '']?.references.indexOf(reference) ?? -1; | ||||||
| 
 | 
 | ||||||
|         if (!constructorInstances || !instanceId || index === -1) { |         if (!constructorInstances || instanceId === undefined || index === -1) { | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -12,7 +12,7 @@ | |||||||
| // See the License for the specific language governing permissions and
 | // See the License for the specific language governing permissions and
 | ||||||
| // limitations under the License.
 | // limitations under the License.
 | ||||||
| 
 | 
 | ||||||
| import { ActivatedRoute, ActivatedRouteSnapshot, Params } from '@angular/router'; | import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||||
| import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; | import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; | ||||||
| 
 | 
 | ||||||
| import { CoreItemsManagerSource } from './items-manager-source'; | import { CoreItemsManagerSource } from './items-manager-source'; | ||||||
| @ -21,13 +21,13 @@ import { CoreItemsManagerSourcesTracker } from './items-manager-sources-tracker' | |||||||
| /** | /** | ||||||
|  * Helper to manage a collection of items in a page. |  * Helper to manage a collection of items in a page. | ||||||
|  */ |  */ | ||||||
| export abstract class CoreItemsManager<Item = unknown> { | export abstract class CoreItemsManager<Item = unknown, Source extends CoreItemsManagerSource<Item> = CoreItemsManagerSource<Item>> { | ||||||
| 
 | 
 | ||||||
|     protected source?: { instance: CoreItemsManagerSource<Item>; unsubscribe: () => void }; |     protected source?: { instance: Source; unsubscribe: () => void }; | ||||||
|     protected itemsMap: Record<string, Item> | null = null; |     protected itemsMap: Record<string, Item> | null = null; | ||||||
|     protected selectedItem: Item | null = null; |     protected selectedItem: Item | null = null; | ||||||
| 
 | 
 | ||||||
|     constructor(source: CoreItemsManagerSource<Item>) { |     constructor(source: Source) { | ||||||
|         this.setSource(source); |         this.setSource(source); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -36,7 +36,7 @@ export abstract class CoreItemsManager<Item = unknown> { | |||||||
|      * |      * | ||||||
|      * @returns Source. |      * @returns Source. | ||||||
|      */ |      */ | ||||||
|     getSource(): CoreItemsManagerSource<Item> { |     getSource(): Source { | ||||||
|         if (!this.source) { |         if (!this.source) { | ||||||
|             throw new Error('Source is missing from items manager'); |             throw new Error('Source is missing from items manager'); | ||||||
|         } |         } | ||||||
| @ -49,7 +49,7 @@ export abstract class CoreItemsManager<Item = unknown> { | |||||||
|      * |      * | ||||||
|      * @param newSource New source. |      * @param newSource New source. | ||||||
|      */ |      */ | ||||||
|     setSource(newSource: CoreItemsManagerSource<Item> | null): void { |     setSource(newSource: Source | null): void { | ||||||
|         if (this.source) { |         if (this.source) { | ||||||
|             CoreItemsManagerSourcesTracker.removeReference(this.source.instance, this); |             CoreItemsManagerSourcesTracker.removeReference(this.source.instance, this); | ||||||
| 
 | 
 | ||||||
| @ -92,31 +92,26 @@ export abstract class CoreItemsManager<Item = unknown> { | |||||||
|      */ |      */ | ||||||
|     protected abstract getCurrentPageRoute(): ActivatedRoute | null; |     protected abstract getCurrentPageRoute(): ActivatedRoute | null; | ||||||
| 
 | 
 | ||||||
|     /** |  | ||||||
|      * Get the path to use when navigating to an item page. |  | ||||||
|      * |  | ||||||
|      * @param item Item. |  | ||||||
|      * @return Path to use when navigating to the item page. |  | ||||||
|      */ |  | ||||||
|     protected abstract getItemPath(item: Item): string; |  | ||||||
| 
 |  | ||||||
|     /** |     /** | ||||||
|      * Get the path of the selected item given the current route. |      * Get the path of the selected item given the current route. | ||||||
|      * |      * | ||||||
|      * @param route Page route. |      * @param route Page route. | ||||||
|      * @return Path of the selected item in the given route. |      * @return Path of the selected item in the given route. | ||||||
|      */ |      */ | ||||||
|     protected abstract getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null; |     protected abstract getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null; | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Get the query parameters to use when navigating to an item page. |      * Get the path of the selected item. | ||||||
|      * |      * | ||||||
|      * @param item Item. |      * @param route Page route, if any. | ||||||
|      * @return Query parameters to use when navigating to the item page. |      * @return Path of the selected item. | ||||||
|      */ |      */ | ||||||
|     // eslint-disable-next-line @typescript-eslint/no-unused-vars
 |     protected getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null { | ||||||
|     protected getItemQueryParams(item: Item): Params { |         if (!route) { | ||||||
|         return {}; |             return null; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return this.getSelectedItemPathFromRoute(route); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -152,7 +147,7 @@ export abstract class CoreItemsManager<Item = unknown> { | |||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         // If this item is already selected, do nothing.
 |         // If this item is already selected, do nothing.
 | ||||||
|         const itemPath = this.getItemPath(item); |         const itemPath = this.getSource().getItemPath(item); | ||||||
|         const selectedItemPath = this.getSelectedItemPath(route.snapshot); |         const selectedItemPath = this.getSelectedItemPath(route.snapshot); | ||||||
| 
 | 
 | ||||||
|         if (selectedItemPath === itemPath) { |         if (selectedItemPath === itemPath) { | ||||||
| @ -160,7 +155,7 @@ export abstract class CoreItemsManager<Item = unknown> { | |||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         // Navigate to item.
 |         // Navigate to item.
 | ||||||
|         const params = this.getItemQueryParams(item); |         const params = this.getSource().getItemQueryParams(item); | ||||||
|         const pathPrefix = selectedItemPath ? selectedItemPath.split('/').fill('../').join('') : ''; |         const pathPrefix = selectedItemPath ? selectedItemPath.split('/').fill('../').join('') : ''; | ||||||
| 
 | 
 | ||||||
|         await CoreNavigator.navigate(pathPrefix + itemPath, { params, ...options }); |         await CoreNavigator.navigate(pathPrefix + itemPath, { params, ...options }); | ||||||
| @ -173,7 +168,7 @@ export abstract class CoreItemsManager<Item = unknown> { | |||||||
|      */ |      */ | ||||||
|     protected onSourceItemsUpdated(items: Item[]): void { |     protected onSourceItemsUpdated(items: Item[]): void { | ||||||
|         this.itemsMap = items.reduce((map, item) => { |         this.itemsMap = items.reduce((map, item) => { | ||||||
|             map[this.getItemPath(item)] = item; |             map[this.getSource().getItemPath(item)] = item; | ||||||
| 
 | 
 | ||||||
|             return map; |             return map; | ||||||
|         }, {}); |         }, {}); | ||||||
|  | |||||||
| @ -26,13 +26,16 @@ import { CoreItemsManagerSource } from './items-manager-source'; | |||||||
| /** | /** | ||||||
|  * Helper class to manage the state and routing of a list of items in a page. |  * Helper class to manage the state and routing of a list of items in a page. | ||||||
|  */ |  */ | ||||||
| export abstract class CoreListItemsManager<Item = unknown> extends CoreItemsManager<Item> { | export class CoreListItemsManager< | ||||||
|  |     Item = unknown, | ||||||
|  |     Source extends CoreItemsManagerSource<Item> = CoreItemsManagerSource<Item> | ||||||
|  | > extends CoreItemsManager<Item, Source> { | ||||||
| 
 | 
 | ||||||
|     protected pageRouteLocator?: unknown | ActivatedRoute; |     protected pageRouteLocator?: unknown | ActivatedRoute; | ||||||
|     protected splitView?: CoreSplitViewComponent; |     protected splitView?: CoreSplitViewComponent; | ||||||
|     protected splitViewOutletSubscription?: Subscription; |     protected splitViewOutletSubscription?: Subscription; | ||||||
| 
 | 
 | ||||||
|     constructor(source: CoreItemsManagerSource<Item>, pageRouteLocator: unknown | ActivatedRoute) { |     constructor(source: Source, pageRouteLocator: unknown | ActivatedRoute) { | ||||||
|         super(source); |         super(source); | ||||||
| 
 | 
 | ||||||
|         this.pageRouteLocator = pageRouteLocator; |         this.pageRouteLocator = pageRouteLocator; | ||||||
| @ -67,15 +70,6 @@ export abstract class CoreListItemsManager<Item = unknown> extends CoreItemsMana | |||||||
|         // Calculate current selected item.
 |         // Calculate current selected item.
 | ||||||
|         this.updateSelectedItem(); |         this.updateSelectedItem(); | ||||||
| 
 | 
 | ||||||
|         // Select default item if none is selected on a non-mobile layout.
 |  | ||||||
|         if (!CoreScreen.isMobile && this.selectedItem === null && !splitView.isNested) { |  | ||||||
|             const defaultItem = this.getDefaultItem(); |  | ||||||
| 
 |  | ||||||
|             if (defaultItem) { |  | ||||||
|                 this.select(defaultItem); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // Log activity.
 |         // Log activity.
 | ||||||
|         await CoreUtils.ignoreErrors(this.logActivity()); |         await CoreUtils.ignoreErrors(this.logActivity()); | ||||||
|     } |     } | ||||||
| @ -146,10 +140,10 @@ export abstract class CoreListItemsManager<Item = unknown> extends CoreItemsMana | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Load items for the next page, if any. |      * Load more items, if any. | ||||||
|      */ |      */ | ||||||
|     async loadNextPage(): Promise<void> { |     async load(): Promise<void> { | ||||||
|         await this.getSource().loadNextPage(); |         await this.getSource().load(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -172,6 +166,25 @@ export abstract class CoreListItemsManager<Item = unknown> extends CoreItemsMana | |||||||
|         return !!this.splitView && !this.splitView?.isNested; |         return !!this.splitView && !this.splitView?.isNested; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     protected updateSelectedItem(route: ActivatedRouteSnapshot | null = null): void { | ||||||
|  |         super.updateSelectedItem(route); | ||||||
|  | 
 | ||||||
|  |         if (CoreScreen.isMobile || this.selectedItem !== null || this.splitView?.isNested) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const defaultItem = this.getDefaultItem(); | ||||||
|  | 
 | ||||||
|  |         if (!defaultItem) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         this.select(defaultItem); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * Get the item that should be selected by default. |      * Get the item that should be selected by default. | ||||||
|      */ |      */ | ||||||
| @ -193,10 +206,12 @@ export abstract class CoreListItemsManager<Item = unknown> extends CoreItemsMana | |||||||
|     /** |     /** | ||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     protected getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null { |     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||||
|         const segments: UrlSegment[] = []; |         const segments: UrlSegment[] = []; | ||||||
| 
 | 
 | ||||||
|         while ((route = route?.firstChild)) { |         while (route.firstChild) { | ||||||
|  |             route = route.firstChild; | ||||||
|  | 
 | ||||||
|             segments.push(...route.url); |             segments.push(...route.url); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -12,16 +12,21 @@ | |||||||
| // See the License for the specific language governing permissions and
 | // See the License for the specific language governing permissions and
 | ||||||
| // limitations under the License.
 | // limitations under the License.
 | ||||||
| 
 | 
 | ||||||
| import { ActivatedRoute } from '@angular/router'; | import { ActivatedRoute, ActivatedRouteSnapshot, UrlSegment } from '@angular/router'; | ||||||
| 
 | 
 | ||||||
| import { CoreNavigator } from '@services/navigator'; | import { CoreNavigator } from '@services/navigator'; | ||||||
| 
 | 
 | ||||||
| import { CoreItemsManager } from './items-manager'; | import { CoreItemsManager } from './items-manager'; | ||||||
|  | import { CoreItemsManagerSource } from './items-manager-source'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Helper class to manage the state and routing of a swipeable page. |  * Helper class to manage the state and routing of a swipeable page. | ||||||
|  */ |  */ | ||||||
| export abstract class CoreSwipeItemsManager<Item = unknown> extends CoreItemsManager<Item> { | export class CoreSwipeItemsManager< | ||||||
|  |     Item = unknown, | ||||||
|  |     Source extends CoreItemsManagerSource<Item> = CoreItemsManagerSource<Item> | ||||||
|  | > | ||||||
|  |     extends CoreItemsManager<Item, Source> { | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Process page started operations. |      * Process page started operations. | ||||||
| @ -51,6 +56,25 @@ export abstract class CoreSwipeItemsManager<Item = unknown> extends CoreItemsMan | |||||||
|         return CoreNavigator.getCurrentRoute(); |         return CoreNavigator.getCurrentRoute(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||||
|  |         const segments: UrlSegment[] = []; | ||||||
|  | 
 | ||||||
|  |         while (route) { | ||||||
|  |             segments.push(...route.url); | ||||||
|  | 
 | ||||||
|  |             if (!route.firstChild) { | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             route = route.firstChild; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return segments.map(segment => segment.path).join('/').replace(/\/+/, '/').trim() || null; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * Navigate to an item by an offset. |      * Navigate to an item by an offset. | ||||||
|      * |      * | ||||||
| @ -86,7 +110,7 @@ export abstract class CoreSwipeItemsManager<Item = unknown> extends CoreItemsMan | |||||||
|         const item = items?.[index + delta] ?? null; |         const item = items?.[index + delta] ?? null; | ||||||
| 
 | 
 | ||||||
|         if (!item && !this.getSource().isCompleted()) { |         if (!item && !this.getSource().isCompleted()) { | ||||||
|             await this.getSource().loadNextPage(); |             await this.getSource().load(); | ||||||
| 
 | 
 | ||||||
|             return this.getItemBy(delta); |             return this.getItemBy(delta); | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| <ion-slides [options]="{ allowTouchMove: !!manager }" (swipeleft)="swipeLeft()" (swiperight)="swipeRight()"> | <ion-slides [options]="{ allowTouchMove: enabled }" (swipeleft)="swipeLeft()" (swiperight)="swipeRight()"> | ||||||
|     <ion-slide> |     <ion-slide> | ||||||
|         <ng-content></ng-content> |         <ng-content></ng-content> | ||||||
|     </ion-slide> |     </ion-slide> | ||||||
|  | |||||||
| @ -5,3 +5,15 @@ ion-slides { | |||||||
| ion-slide { | ion-slide { | ||||||
|     align-items: start; |     align-items: start; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | ::ng-deep { | ||||||
|  | 
 | ||||||
|  |     core-loading .core-loading-content { | ||||||
|  |         width: 100%; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     ion-refresher.refresher-native { | ||||||
|  |         z-index: 2; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | |||||||
| @ -14,6 +14,7 @@ | |||||||
| 
 | 
 | ||||||
| import { Component, Input } from '@angular/core'; | import { Component, Input } from '@angular/core'; | ||||||
| import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||||
|  | import { CoreScreen } from '@services/screen'; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|     selector: 'core-swipe-navigation', |     selector: 'core-swipe-navigation', | ||||||
| @ -24,10 +25,18 @@ export class CoreSwipeNavigationComponent { | |||||||
| 
 | 
 | ||||||
|     @Input() manager?: CoreSwipeItemsManager; |     @Input() manager?: CoreSwipeItemsManager; | ||||||
| 
 | 
 | ||||||
|  |     get enabled(): boolean { | ||||||
|  |         return CoreScreen.isMobile && !!this.manager; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * Swipe to previous item. |      * Swipe to previous item. | ||||||
|      */ |      */ | ||||||
|     swipeLeft(): void { |     swipeLeft(): void { | ||||||
|  |         if (!this.enabled) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|         this.manager?.navigateToPreviousItem(); |         this.manager?.navigateToPreviousItem(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -35,6 +44,10 @@ export class CoreSwipeNavigationComponent { | |||||||
|      * Swipe to next item. |      * Swipe to next item. | ||||||
|      */ |      */ | ||||||
|     swipeRight(): void { |     swipeRight(): void { | ||||||
|  |         if (!this.enabled) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|         this.manager?.navigateToNextItem(); |         this.manager?.navigateToNextItem(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ | |||||||
| // See the License for the specific language governing permissions and
 | // See the License for the specific language governing permissions and
 | ||||||
| // limitations under the License.
 | // limitations under the License.
 | ||||||
| 
 | 
 | ||||||
|  | import { Params } from '@angular/router'; | ||||||
| import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||||
| 
 | 
 | ||||||
| import { CoreUser, CoreUserData, CoreUserParticipant, CoreUserProvider } from '../services/user'; | import { CoreUser, CoreUserData, CoreUserParticipant, CoreUserProvider } from '../services/user'; | ||||||
| @ -40,6 +41,20 @@ export class CoreUserParticipantsSource extends CoreItemsManagerSource<CoreUserP | |||||||
|         this.SEARCH_QUERY = searchQuery; |         this.SEARCH_QUERY = searchQuery; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     getItemPath(user: CoreUserParticipant | CoreUserData): string { | ||||||
|  |         return user.id.toString(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     getItemQueryParams(): Params { | ||||||
|  |         return { search: this.SEARCH_QUERY }; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|  | |||||||
| @ -13,7 +13,6 @@ | |||||||
| // limitations under the License.
 | // limitations under the License.
 | ||||||
| 
 | 
 | ||||||
| import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; | import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; | ||||||
| import { Params } from '@angular/router'; |  | ||||||
| import { IonRefresher } from '@ionic/angular'; | import { IonRefresher } from '@ionic/angular'; | ||||||
| 
 | 
 | ||||||
| import { CoreApp } from '@services/app'; | import { CoreApp } from '@services/app'; | ||||||
| @ -50,7 +49,7 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro | |||||||
|             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); |             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||||
|             this.participants = new CoreUserParticipantsManager( |             this.participants = new CoreUserParticipantsManager( | ||||||
|                 CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]), |                 CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]), | ||||||
|                 this, |                 CoreUserParticipantsPage, | ||||||
|             ); |             ); | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             CoreDomUtils.showErrorModal(error); |             CoreDomUtils.showErrorModal(error); | ||||||
| @ -186,7 +185,7 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro | |||||||
|     private async fetchParticipants(reload: boolean): Promise<void> { |     private async fetchParticipants(reload: boolean): Promise<void> { | ||||||
|         reload |         reload | ||||||
|             ? await this.participants.reload() |             ? await this.participants.reload() | ||||||
|             : await this.participants.loadNextPage(); |             : await this.participants.load(); | ||||||
| 
 | 
 | ||||||
|         this.fetchMoreParticipantsFailed = false; |         this.fetchMoreParticipantsFailed = false; | ||||||
|     } |     } | ||||||
| @ -196,35 +195,13 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro | |||||||
| /** | /** | ||||||
|  * Helper to manage the list of participants. |  * Helper to manage the list of participants. | ||||||
|  */ |  */ | ||||||
| class CoreUserParticipantsManager extends CoreListItemsManager<CoreUserParticipant | CoreUserData> { | class CoreUserParticipantsManager extends CoreListItemsManager<CoreUserParticipant | CoreUserData, CoreUserParticipantsSource> { | ||||||
| 
 |  | ||||||
|     page: CoreUserParticipantsPage; |  | ||||||
| 
 |  | ||||||
|     constructor(source: CoreUserParticipantsSource, page: CoreUserParticipantsPage) { |  | ||||||
|         super(source, CoreUserParticipantsPage); |  | ||||||
| 
 |  | ||||||
|         this.page = page; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * @inheritdoc |  | ||||||
|      */ |  | ||||||
|     protected getItemPath(participant: CoreUserParticipant | CoreUserData): string { |  | ||||||
|         return participant.id.toString(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * @inheritdoc |  | ||||||
|      */ |  | ||||||
|     protected getItemQueryParams(): Params { |  | ||||||
|         return { search: this.page.searchQuery }; |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     protected async logActivity(): Promise<void> { |     protected async logActivity(): Promise<void> { | ||||||
|         await CoreUser.logParticipantsView(this.page.courseId); |         await CoreUser.logParticipantsView(this.getSource().COURSE_ID); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| } | } | ||||||
|  | |||||||
| @ -12,7 +12,7 @@ | |||||||
| // See the License for the specific language governing permissions and
 | // See the License for the specific language governing permissions and
 | ||||||
| // limitations under the License.
 | // limitations under the License.
 | ||||||
| 
 | 
 | ||||||
| import { ActivatedRoute, ActivatedRouteSnapshot, Params } from '@angular/router'; | import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||||
| import { Component, OnDestroy, OnInit } from '@angular/core'; | import { Component, OnDestroy, OnInit } from '@angular/core'; | ||||||
| import { IonRefresher } from '@ionic/angular'; | import { IonRefresher } from '@ionic/angular'; | ||||||
| import { Subscription } from 'rxjs'; | import { Subscription } from 'rxjs'; | ||||||
| @ -21,7 +21,7 @@ import { CoreSite } from '@classes/site'; | |||||||
| import { CoreSites } from '@services/sites'; | import { CoreSites } from '@services/sites'; | ||||||
| import { CoreDomUtils } from '@services/utils/dom'; | import { CoreDomUtils } from '@services/utils/dom'; | ||||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||||
| import { CoreUser, CoreUserBasicData, CoreUserProfile, CoreUserProvider } from '@features/user/services/user'; | import { CoreUser, CoreUserProfile, CoreUserProvider } from '@features/user/services/user'; | ||||||
| import { CoreUserHelper } from '@features/user/services/user-helper'; | import { CoreUserHelper } from '@features/user/services/user-helper'; | ||||||
| import { CoreUserDelegate, CoreUserDelegateService, CoreUserProfileHandlerData } from '@features/user/services/user-delegate'; | import { CoreUserDelegate, CoreUserDelegateService, CoreUserProfileHandlerData } from '@features/user/services/user-delegate'; | ||||||
| import { CoreUtils } from '@services/utils/utils'; | import { CoreUtils } from '@services/utils/utils'; | ||||||
| @ -30,7 +30,6 @@ import { CoreCourses } from '@features/courses/services/courses'; | |||||||
| import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||||
| import { CoreUserParticipantsSource } from '@features/user/classes/participants-source'; | import { CoreUserParticipantsSource } from '@features/user/classes/participants-source'; | ||||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||||
| import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; |  | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|     selector: 'page-core-user-profile', |     selector: 'page-core-user-profile', | ||||||
| @ -57,7 +56,6 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { | |||||||
|     communicationHandlers: CoreUserProfileHandlerData[] = []; |     communicationHandlers: CoreUserProfileHandlerData[] = []; | ||||||
| 
 | 
 | ||||||
|     users?: CoreUserSwipeItemsManager; |     users?: CoreUserSwipeItemsManager; | ||||||
|     usersQueryParams: Params = {}; |  | ||||||
| 
 | 
 | ||||||
|     constructor(private route: ActivatedRoute) { |     constructor(private route: ActivatedRoute) { | ||||||
|         this.obsProfileRefreshed = CoreEvents.on(CoreUserProvider.PROFILE_REFRESHED, (data) => { |         this.obsProfileRefreshed = CoreEvents.on(CoreUserProvider.PROFILE_REFRESHED, (data) => { | ||||||
| @ -93,9 +91,8 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { | |||||||
|         if (this.courseId && this.route.snapshot.data.swipeManagerSource === 'participants') { |         if (this.courseId && this.route.snapshot.data.swipeManagerSource === 'participants') { | ||||||
|             const search = CoreNavigator.getRouteParam('search'); |             const search = CoreNavigator.getRouteParam('search'); | ||||||
|             const source = CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId, search]); |             const source = CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId, search]); | ||||||
|             this.users = new CoreUserSwipeItemsManager(source, this); |             this.users = new CoreUserSwipeItemsManager(source); | ||||||
| 
 | 
 | ||||||
|             this.usersQueryParams.search = search; |  | ||||||
|             this.users.start(); |             this.users.start(); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
| @ -227,38 +224,12 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { | |||||||
| /** | /** | ||||||
|  * Helper to manage swiping within a collection of users. |  * Helper to manage swiping within a collection of users. | ||||||
|  */ |  */ | ||||||
| class CoreUserSwipeItemsManager extends CoreSwipeItemsManager<CoreUserBasicData> { | class CoreUserSwipeItemsManager extends CoreSwipeItemsManager { | ||||||
| 
 |  | ||||||
|     page: CoreUserProfilePage; |  | ||||||
| 
 |  | ||||||
|     constructor(source: CoreItemsManagerSource<CoreUserBasicData>, page: CoreUserProfilePage) { |  | ||||||
|         super(source); |  | ||||||
| 
 |  | ||||||
|         this.page = page; |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     protected getItemPath(item: CoreUserBasicData): string { |     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||||
|         return String(item.id); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * @inheritdoc |  | ||||||
|      */ |  | ||||||
|     protected getItemQueryParams(): Params { |  | ||||||
|         return this.page.usersQueryParams; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * @inheritdoc |  | ||||||
|      */ |  | ||||||
|     protected getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null { |  | ||||||
|         if (!route) { |  | ||||||
|             return null; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return route.params.userId; |         return route.params.userId; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -19,9 +19,6 @@ | |||||||
|                 right: calc(50% - 12px -  var(--core-avatar-size) / 2) !important; |                 right: calc(50% - 12px -  var(--core-avatar-size) / 2) !important; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|         core-loading .core-loading-content { |  | ||||||
|             width: 100%; |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| } | } | ||||||
|  | |||||||
| @ -397,8 +397,14 @@ export class CoreUserDelegateService extends CoreDelegate<CoreUserProfileHandler | |||||||
|      */ |      */ | ||||||
|     protected clearHandlerCache(courseId?: number, userId?: number): void { |     protected clearHandlerCache(courseId?: number, userId?: number): void { | ||||||
|         if (courseId && userId) { |         if (courseId && userId) { | ||||||
|  |             const cacheKey = this.getCacheKey(courseId, userId); | ||||||
|  | 
 | ||||||
|             Object.keys(this.enabledHandlers).forEach((name) => { |             Object.keys(this.enabledHandlers).forEach((name) => { | ||||||
|                 delete this.enabledForUserCache[name][this.getCacheKey(courseId, userId)]; |                 const cache = this.enabledForUserCache[name]; | ||||||
|  | 
 | ||||||
|  |                 if (cache) { | ||||||
|  |                     delete cache[cacheKey]; | ||||||
|  |                 } | ||||||
|             }); |             }); | ||||||
|         } else { |         } else { | ||||||
|             this.enabledForUserCache = {}; |             this.enabledForUserCache = {}; | ||||||
|  | |||||||
| @ -290,7 +290,7 @@ export class CoreNavigatorService { | |||||||
|      * @param routeOptions Optional routeOptions to get the params or route value from. If missing, it will autodetect. |      * @param routeOptions Optional routeOptions to get the params or route value from. If missing, it will autodetect. | ||||||
|      * @return Value of the parameter, undefined if not found. |      * @return Value of the parameter, undefined if not found. | ||||||
|      */ |      */ | ||||||
|     getRouteParam<T = unknown>(name: string, routeOptions: CoreNavigatorCurrentRouteOptions = {}): T | undefined { |     getRouteParam<T = string>(name: string, routeOptions: CoreNavigatorCurrentRouteOptions = {}): T | undefined { | ||||||
|         // eslint-disable-next-line @typescript-eslint/no-explicit-any
 |         // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||||
|         let value: any; |         let value: any; | ||||||
| 
 | 
 | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user