Merge pull request #3012 from NoelDeMartin/MOBILE-3926

MOBILE-3926: Add swipe navigation to pages with split-view
main
Dani Palou 2021-12-02 14:14:54 +01:00 committed by GitHub
commit ad6c7367ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 2419 additions and 1579 deletions

View 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 };
}
}

View File

@ -10,239 +10,241 @@
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!badgeLoaded" (ionRefresh)="refreshBadges($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="badgeLoaded">
<ion-item-group *ngIf="badge">
<ion-item class="ion-text-wrap ion-text-center">
<ion-label>
<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>
<core-swipe-navigation [manager]="badges">
<ion-refresher slot="fixed" [disabled]="!badgeLoaded" (ionRefresh)="refreshBadges($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="badgeLoaded">
<ion-item-group *ngIf="badge">
<ion-item class="ion-text-wrap ion-text-center">
<ion-label>
<h2>{{ 'addon.badges.issuerdetails' | translate}}</h2>
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="badge.issuername">
<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>
<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>
<ion-item-group *ngIf="user">
<ion-item-divider>
<ion-label>
<h2>{{ 'addon.badges.badgedetails' | translate}}</h2>
<h2>{{ 'addon.badges.recipientdetails' | translate}}</h2>
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="badge.name">
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ 'core.name' | translate}}</h2>
<p>{{ badge.name }}</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>
<p>{{ user.fullname }}</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>
<ng-container *ngIf="badge">
<ion-item-group>
<ion-item-divider>
<ion-label>
<h2>{{ 'addon.badges.issuerdetails' | translate}}</h2>
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="badge.issuername">
<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-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>
<ion-item-group>
<ion-item-divider>
<ion-label>
<h2>{{ 'addon.badges.badgedetails' | translate}}</h2>
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="badge.name">
<ion-label>
<h2>{{ 'core.name' | translate}}</h2>
<p>{{ badge.name }}</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-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>

View File

@ -23,6 +23,9 @@ import { CoreUtils } from '@services/utils/utils';
import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses';
import { CoreNavigator } from '@services/navigator';
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.
@ -40,12 +43,11 @@ export class AddonBadgesIssuedBadgePage implements OnInit {
user?: CoreUserProfile;
course?: CoreEnrolledCourseData;
badge?: AddonBadgesUserBadge;
badges?: CoreSwipeItemsManager;
badgeLoaded = false;
currentTime = 0;
constructor(
protected route: ActivatedRoute,
) { }
constructor(protected route: ActivatedRoute) { }
/**
* View loaded.
@ -58,6 +60,11 @@ export class AddonBadgesIssuedBadgePage implements OnInit {
this.fetchIssuedBadge().finally(() => {
this.badgeLoaded = true;
});
const source = CoreItemsManagerSourcesTracker.getOrCreateSource(AddonBadgesUserBadgesSource, [this.courseId, this.userId]);
this.badges = new CoreSwipeItemsManager(source);
this.badges.start();
}
/**

View File

@ -19,10 +19,11 @@ import { CoreTimeUtils } from '@services/utils/time';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreSites } from '@services/sites';
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 { 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.
@ -34,7 +35,7 @@ import { CoreNavigator } from '@services/navigator';
export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy {
currentTime = 0;
badges: AddonBadgesUserBadgesManager;
badges: CoreListItemsManager<AddonBadgesUserBadge, AddonBadgesUserBadgesSource>;
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
@ -47,7 +48,10 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy {
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.
*/
async refreshBadges(refresher?: IonRefresher): Promise<void> {
await CoreUtils.ignoreErrors(AddonBadges.invalidateUserBadges(this.badges.courseId, this.badges.userId));
await CoreUtils.ignoreErrors(this.fetchBadges());
await CoreUtils.ignoreErrors(
AddonBadges.invalidateUserBadges(
this.badges.getSource().COURSE_ID,
this.badges.getSource().USER_ID,
),
);
await CoreUtils.ignoreErrors(this.badges.reload());
refresher?.complete();
}
@ -85,55 +94,12 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy {
this.currentTime = CoreTimeUtils.timestamp();
try {
await this.fetchBadges();
await this.badges.reload();
} catch (message) {
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,
};
}
}

View 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.
};

View File

@ -16,10 +16,10 @@
<ion-content>
<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>
<core-loading [hideUntil]="loaded && submissions.loaded">
<core-loading [hideUntil]="submissions.loaded">
<core-empty-box *ngIf="!submissions || submissions.empty" icon="fas-file-signature"
[message]="'addon.mod_assign.submissionstatus_' | translate">
</core-empty-box>
@ -32,7 +32,7 @@
<ion-label id="addon-assign-groupslabel" *ngIf="groupInfo.visibleGroups">
{{ 'core.groupsvisible' | translate }}
</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}">
<ion-select-option *ngFor="let groupOpt of groupInfo.groups" [value]="groupOpt.id">
{{groupOpt.name}}

View File

@ -13,29 +13,20 @@
// limitations under the License.
import { Component, OnDestroy, AfterViewInit, ViewChild } from '@angular/core';
import { Params } 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 { IonRefresher } from '@ionic/angular';
import { CoreGroupInfo, CoreGroups } from '@services/groups';
import { CoreGroupInfo } from '@services/groups';
import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import {
AddonModAssignAssign,
AddonModAssignSubmission,
AddonModAssignProvider,
AddonModAssign,
AddonModAssignGrade,
} from '../../services/assign';
import { AddonModAssignHelper, AddonModAssignSubmissionFormatted } from '../../services/assign-helper';
import { AddonModAssignOffline } from '../../services/assign-offline';
import { AddonModAssignSubmissionForList, AddonModAssignSubmissionsSource } from '../../classes/submissions-source';
import { AddonModAssignAssign, AddonModAssignProvider } from '../../services/assign';
import {
AddonModAssignSyncProvider,
AddonModAssignSync,
AddonModAssignManualSyncData,
AddonModAssignAutoSyncData,
} from '../../services/assign-sync';
@ -51,47 +42,26 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
title = ''; // Title to display.
assign?: AddonModAssignAssign; // Assignment.
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.
title = '';
submissions!: CoreListItemsManager<AddonModAssignSubmissionForList, AddonModAssignSubmissionsSource>; // List of submissions
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 syncObserver: CoreEventObserver; // Observer to refresh data when the async is synchronized.
protected submissionsData: { canviewsubmissions: boolean; submissions?: AddonModAssignSubmission[] } = {
canviewsubmissions: false,
};
protected sourceUnsubscribe?: () => void;
constructor() {
this.submissions = new AddonModAssignSubmissionListManager(AddonModAssignSubmissionListPage);
// Update data if some grade changes.
this.gradedObserver = CoreEvents.on(
AddonModAssignProvider.GRADED_EVENT,
(data) => {
if (
this.loaded &&
this.assign &&
data.assignmentId == this.assign.id &&
this.submissions.loaded &&
this.submissions.getSource().assign &&
data.assignmentId == this.submissions.getSource().assign?.id &&
data.userId == CoreSites.getCurrentSiteUserId()
) {
// Grade changed, refresh the data.
this.loaded = false;
this.refreshAllData(true).finally(() => {
this.loaded = true;
});
this.refreshAllData(true);
}
},
CoreSites.getCurrentSiteId(),
@ -102,29 +72,36 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro
this.syncObserver = CoreEvents.onMultiple<AddonModAssignAutoSyncData | AddonModAssignManualSyncData>(
events,
(data) => {
if (!this.loaded || ('context' in data && data.context == 'submission-list')) {
if (!this.submissions.loaded || ('context' in data && data.context == 'submission-list')) {
return;
}
this.loaded = false;
this.refreshAllData(false).finally(() => {
this.loaded = true;
});
this.refreshAllData(false);
},
CoreSites.getCurrentSiteId(),
);
}
/**
* Component being initialized.
*/
ngAfterViewInit(): void {
try {
this.moduleId = CoreNavigator.getRequiredRouteNumberParam('cmId');
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.groupId = CoreNavigator.getRouteNumberParam('groupId') || 0;
this.selectedStatus = CoreNavigator.getRouteParam('status');
const moduleId = CoreNavigator.getRequiredRouteNumberParam('cmId');
const courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
const groupId = CoreNavigator.getRouteNumberParam('groupId') || 0;
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) {
CoreDomUtils.showErrorModal(error);
@ -132,18 +109,48 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro
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.loaded = true;
this.submissions.start(this.splitView);
});
}
@ -156,148 +163,12 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro
*/
protected async fetchAssignment(sync = false): Promise<void> {
try {
// Get assignment data.
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));
await this.submissions.getSource().loadAssignment(sync);
} catch (error) {
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.
*
@ -305,18 +176,8 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro
* @return Promise resolved when done.
*/
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 {
await Promise.all(promises);
await this.submissions.getSource().invalidateCache();
} finally {
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.
*/
@ -340,43 +208,7 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro
this.gradedObserver?.off();
this.syncObserver?.off();
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.
};

View File

@ -20,12 +20,14 @@
</core-navbar-buttons>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshSubmission($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<addon-mod-assign-submission [courseId]="courseId" [moduleId]="moduleId" [submitId]="submitId" [blindId]="blindId">
</addon-mod-assign-submission>
</core-loading>
<core-swipe-navigation [manager]="submissions">
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshSubmission($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<addon-mod-assign-submission *ngIf="loaded"
[courseId]="courseId" [moduleId]="moduleId" [submitId]="submitId" [blindId]="blindId">
</addon-mod-assign-submission>
</core-loading>
</core-swipe-navigation>
</ion-content>

View File

@ -12,14 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
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 { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager';
import { CoreCourse } from '@features/course/services/course';
import { CanLeave } from '@guards/can-leave';
import { IonRefresher } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator';
import { CoreScreen } from '@services/screen';
import { CoreDomUtils } from '@services/utils/dom';
import { AddonModAssignSubmissionsSource } from '../../classes/submissions-source';
import { AddonModAssignSubmissionComponent } from '../../components/submission/submission';
import { AddonModAssign, AddonModAssignAssign } from '../../services/assign';
@ -30,11 +33,12 @@ import { AddonModAssign, AddonModAssignAssign } from '../../services/assign';
selector: 'page-addon-mod-assign-submission-review',
templateUrl: 'submission-review.html',
})
export class AddonModAssignSubmissionReviewPage implements OnInit, CanLeave {
export class AddonModAssignSubmissionReviewPage implements OnInit, OnDestroy, CanLeave {
@ViewChild(AddonModAssignSubmissionComponent) submissionComponent?: AddonModAssignSubmissionComponent;
title = ''; // Title to display.
submissions?: AddonModAssignSubmissionSwipeItemsManager;
moduleId!: number; // Module ID the submission belongs to.
courseId!: number; // Course ID the assignment belongs to.
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 forceLeave = false; // To allow leaving the page without checking for changes.
constructor(
protected route: ActivatedRoute,
) { }
constructor(protected route: ActivatedRoute) { }
/**
* Component being initialized.
@ -60,6 +62,19 @@ export class AddonModAssignSubmissionReviewPage implements OnInit, CanLeave {
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.submitId = CoreNavigator.getRouteNumberParam('submitId') || 0;
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) {
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.
*
@ -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;
}
}

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

View File

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

View File

@ -10,11 +10,11 @@
<core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}" iconAction="far-newspaper"
(action)="gotoBlog()">
</core-context-menu-item>
<core-context-menu-item *ngIf="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"
(action)="doRefresh(null, $event)">
</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"
(action)="doRefresh(null, $event, true)">
</core-context-menu-item>
@ -32,11 +32,11 @@
<!-- Content. -->
<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>
<core-loading [hideUntil]="discussions.loaded">
<core-loading [hideUntil]="discussions && discussions.loaded">
<!-- Activity info. -->
<core-course-module-info [module]="module" (completionChanged)="onCompletionChange()"
[description]="forum && forum.type != 'single' && description" [component]="component" [componentId]="componentId"
@ -57,17 +57,18 @@
</ion-card>
<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>
<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"
[label]="('core.sort' | translate)" (onChange)="setSortOrder($event)" [selection]="selectedSortOrder.label | translate"
interface="modal">
</core-combobox>
</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)"
(click)="discussions.select(discussion)" button>
<ion-label>
@ -96,17 +97,16 @@
<ion-icon name="fas-users" [attr.aria-label]="'addon.mod_forum.group' | translate">
</ion-icon> {{ discussion.groupname }}
</p>
<p *ngIf="discussions.isOnlineDiscussion(discussion)">
<p *ngIf="isOnlineDiscussion(discussion)">
{{discussion.created * 1000 | coreFormatDate: "strftimerecentfull"}}
</p>
<p *ngIf="discussions.isOfflineDiscussion(discussion)">
<p *ngIf="isOfflineDiscussion(discussion)">
<ion-icon name="fas-clock" aria-hidden="true"></ion-icon>
{{ 'core.notsent' | translate }}
</p>
</div>
</div>
<ion-row *ngIf="discussions.isOnlineDiscussion(discussion)"
class="ion-text-center addon-mod-forum-discussion-more-info">
<ion-row *ngIf="isOnlineDiscussion(discussion)" class="ion-text-center addon-mod-forum-discussion-more-info">
<ion-col class="ion-text-start">
<ion-note>
<ion-icon name="fas-clock" aria-hidden="true"></ion-icon> {{ 'addon.mod_forum.lastpost' | translate }}
@ -134,7 +134,7 @@
</ion-label>
</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)">
</core-infinite-loading>
</ng-container>

View File

@ -13,7 +13,7 @@
// limitations under the License.
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 { ModalOptions } from '@ionic/core';
@ -27,7 +27,7 @@ import {
AddonModForumNewDiscussionData,
AddonModForumReplyDiscussionData,
} 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 { CoreCourseContentsPage } from '@features/course/pages/contents/contents';
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 { CoreUtils } from '@services/utils/utils';
import { CoreCourse } from '@features/course/services/course';
import { CorePageItemsListManager } from '@classes/page-items-list-manager';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { AddonModForumDiscussionOptionsMenuComponent } from '../discussion-options-menu/discussion-options-menu';
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 { CoreRatingOffline } from '@features/rating/services/rating-offline';
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.
@ -72,24 +74,21 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
component = AddonModForumProvider.COMPONENT;
moduleName = 'forum';
descriptionNote?: string;
forum?: AddonModForumData;
discussions: AddonModForumDiscussionsManager;
discussions!: AddonModForumDiscussionsManager;
discussionsItems: AddonModForumDiscussionItem[] = [];
fetchFailed = false;
canAddDiscussion = false;
addDiscussionText!: string;
availabilityMessage: string | null = null;
sortingAvailable!: boolean;
sortOrders: AddonModForumSortOrder[] = [];
selectedSortOrder: AddonModForumSortOrder | null = null;
canPin = false;
trackPosts = false;
hasOfflineRatings = false;
sortOrderSelectorModalOptions: ModalOptions = {
component: AddonModForumSortOrderSelectorComponent,
};
protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED;
protected page = 0;
protected usesGroups = false;
protected syncManualObserver?: CoreEventObserver; // It will observe the sync manual event.
protected replyObserver?: CoreEventObserver;
protected newDiscObserver?: CoreEventObserver;
@ -97,19 +96,42 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
protected changeDiscObserver?: CoreEventObserver;
protected ratingOfflineObserver?: CoreEventObserver;
protected ratingSyncObserver?: CoreEventObserver;
protected sourceUnsubscribe?: () => void;
constructor(
route: ActivatedRoute,
public route: ActivatedRoute,
@Optional() protected content?: IonContent,
@Optional() courseContentsPage?: CoreCourseContentsPage,
) {
super('AddonModForumIndexComponent', content, courseContentsPage);
}
this.discussions = new AddonModForumDiscussionsManager(
route.component,
this,
courseContentsPage ? `${AddonModForumModuleHandlerService.PAGE_NAME}/` : '',
);
get forum(): AddonModForumData | undefined {
return this.discussions?.getSource().forum;
}
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();
// 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.
this.syncManualObserver = CoreEvents.on(AddonModForumSyncProvider.MANUAL_SYNCED, (data) => {
this.autoSyncEventReceived(data);
@ -141,12 +205,16 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
this.eventReceived.bind(this, false),
);
this.changeDiscObserver = CoreEvents.on(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data => {
if ((this.forum && this.forum.id === data.forumId) || data.cmId === this.module.id) {
AddonModForum.invalidateDiscussionsList(this.forum!.id).finally(() => {
if (!this.forum) {
return;
}
if (this.forum.id === data.forumId || data.cmId === this.module.id) {
AddonModForum.invalidateDiscussionsList(this.forum.id).finally(() => {
if (data.discussionId) {
// Discussion changed, search it in the list of discussions.
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;
if (discussion) {
@ -196,20 +264,6 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
async ngAfterViewInit(): Promise<void> {
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);
}
@ -226,6 +280,8 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
this.changeDiscObserver && this.changeDiscObserver.off();
this.ratingOfflineObserver && this.ratingOfflineObserver.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.
*/
protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> {
this.discussions.fetchFailed = false;
const promises: Promise<void>[] = [];
promises.push(this.fetchForum(sync, showErrors));
promises.push(this.fetchSortOrderPreference());
this.fetchFailed = false;
try {
await Promise.all(promises);
await Promise.all([
this.fetchOfflineDiscussions(),
this.fetchDiscussions(refresh),
CoreRatingOffline.hasRatings('mod_forum', 'post', ContextLevel.MODULE, this.forum!.cmid).then((hasRatings) => {
this.fetchForum(sync, showErrors),
this.fetchSortOrderPreference(),
]);
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;
return;
@ -258,7 +316,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
if (refresh) {
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 {
// Get forum failed, retry without using cache since it might be a new activity.
await this.refreshContent(sync);
@ -273,19 +331,19 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
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.availabilityMessage = AddonModForumHelper.getAvailabilityMessage(forum);
this.descriptionNote = Translate.instant('addon.mod_forum.numdiscussions', {
numdiscussions: forum.numdiscussions,
});
if (typeof forum.istracked != 'undefined') {
this.trackPosts = forum.istracked;
}
this.dataRetrieved.emit(forum);
switch (forum.type) {
@ -319,10 +377,10 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
// Check if the activity uses groups.
promises.push(
CoreGroups.instance
.getActivityGroupMode(this.forum.cmid)
.getActivityGroupMode(forum.cmid)
.then(async mode => {
this.usesGroups = mode === CoreGroupsProvider.SEPARATEGROUPS
|| mode === CoreGroupsProvider.VISIBLEGROUPS;
this.discussions.getSource().usesGroups =
mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS;
return;
}),
@ -330,14 +388,14 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
promises.push(
AddonModForum.instance
.getAccessInformation(this.forum.id, { cmId: this.module.id })
.getAccessInformation(forum.id, { cmId: this.module.id })
.then(async accessInfo => {
// Disallow adding discussions if cut-off date is reached and the user has not the
// 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.
const cutoffDateReached = AddonModForumHelper.isCutoffDateReached(this.forum!)
const cutoffDateReached = AddonModForumHelper.isCutoffDateReached(forum)
&& !accessInfo.cancanoverridecutoff;
this.canAddDiscussion = !!this.forum?.cancreatediscussions && !cutoffDateReached;
this.canAddDiscussion = !!forum.cancreatediscussions && !cutoffDateReached;
return;
}),
@ -347,7 +405,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
// Use the canAddDiscussion WS to check if the user can pin discussions.
promises.push(
AddonModForum.instance
.canAddDiscussionToAll(this.forum.id, { cmId: this.module.id })
.canAddDiscussionToAll(forum.id, { cmId: this.module.id })
.then(async response => {
this.canPin = !!response.canpindiscussions;
@ -366,124 +424,6 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
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.
*
@ -492,11 +432,13 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
*/
async fetchMoreDiscussions(complete: () => void): Promise<void> {
try {
await this.fetchDiscussions(false);
this.fetchFailed = false;
await this.discussions.load();
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true);
this.discussions.fetchFailed = true;
this.fetchFailed = true;
} finally {
complete();
}
@ -521,9 +463,13 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
};
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.sortOrderSelectorModalOptions.componentProps!.selected = this.selectedSortOrder.value;
this.discussions.getSource().selectedSortOrder = selectedOrder;
if (this.sortOrderSelectorModalOptions.componentProps) {
this.sortOrderSelectorModalOptions.componentProps.selected = selectedOrder.value;
}
}
/**
@ -597,11 +543,11 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
if (isNewDiscussion && CoreScreen.isTablet) {
const newDiscussionData = data as AddonModForumNewDiscussionData;
const discussion = this.discussions.items.find(disc => {
if (this.discussions.isOfflineDiscussion(disc)) {
if (this.discussions.getSource().isOfflineDiscussion(disc)) {
return disc.timecreated === newDiscussionData.discTimecreated;
}
if (this.discussions.isOnlineDiscussion(disc)) {
if (this.discussions.getSource().isOnlineDiscussion(disc)) {
return CoreArray.contains(newDiscussionData.discussionIds ?? [], disc.discussion);
}
@ -625,7 +571,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
* @param timeCreated Creation time of the offline discussion.
*/
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.
*/
async setSortOrder(sortOrder: AddonModForumSortOrder): Promise<void> {
if (sortOrder.value != this.selectedSortOrder?.value) {
this.selectedSortOrder = sortOrder;
this.sortOrderSelectorModalOptions.componentProps!.selected = this.selectedSortOrder.value;
this.page = 0;
if (sortOrder.value != this.discussions.getSource().selectedSortOrder?.value) {
this.discussions.getSource().selectedSortOrder = sortOrder;
this.discussions.getSource().setDirty(true);
if (this.sortOrderSelectorModalOptions.componentProps) {
this.sortOrderSelectorModalOptions.componentProps.selected = sortOrder.value;
}
try {
await CoreUser.setUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER, sortOrder.value.toFixed(0));
@ -666,6 +615,10 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
* @param discussion Discussion.
*/
async showOptionsMenu(event: Event, discussion: AddonModForumDiscussion): Promise<void> {
if (!this.forum) {
return;
}
event.preventDefault();
event.stopPropagation();
@ -673,7 +626,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
component: AddonModForumDiscussionOptionsMenuComponent,
componentProps: {
discussion,
forumId: this.forum!.id,
forumId: this.forum.id,
cmId: this.module.id,
},
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.
*/
class AddonModForumDiscussionsManager extends CorePageItemsListManager<DiscussionItem> {
class AddonModForumDiscussionsManager extends CoreListItemsManager<AddonModForumDiscussionItem, AddonModForumDiscussionsSource> {
onlineLoaded = false;
fetchFailed = false;
page: AddonModForumIndexComponent;
private discussionsPathPrefix: string;
private component: AddonModForumIndexComponent;
constructor(source: AddonModForumDiscussionsSource, page: AddonModForumIndexComponent) {
super(source, page.route.component);
constructor(pageComponent: unknown, component: AddonModForumIndexComponent, discussionsPathPrefix: string) {
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[];
this.page = page;
}
/**
* @inheritdoc
*/
getItemQueryParams(discussion: DiscussionItem): Params {
return {
courseId: this.component.courseId,
cmId: this.component.module.id,
forumId: this.component.forum!.id,
...(this.isOnlineDiscussion(discussion) ? { discussion, trackPosts: this.component.trackPosts } : {}),
};
}
protected getDefaultItem(): AddonModForumDiscussionItem | null {
const source = this.getSource();
/**
* 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);
return this.items.find(discussion => !source.isNewDiscussionForm(discussion)) || null;
}
/**
* @inheritdoc
*/
protected getItemPath(discussion: DiscussionItem): string {
const getRelativePath = () => {
if (this.isOnlineDiscussion(discussion)) {
return discussion.discussion;
}
protected async logActivity(): Promise<void> {
const forum = this.getSource().forum;
if (this.isOfflineDiscussion(discussion)) {
return `new/${discussion.timecreated}`;
}
if (!forum) {
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;
}),
);
}
}

View File

@ -55,6 +55,7 @@ const mainMenuRoutes: Routes = [
{
path: `${AddonModForumModuleHandlerService.PAGE_NAME}/discussion/:discussionId`,
loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule),
data: { swipeEnabled: false },
},
{
path: AddonModForumModuleHandlerService.PAGE_NAME,
@ -66,10 +67,12 @@ const mainMenuRoutes: Routes = [
path: `${COURSE_CONTENTS_PATH}/${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`,
loadChildren: () => import('./pages/new-discussion/new-discussion.module')
.then(m => m.AddonForumNewDiscussionPageModule),
data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` },
},
{
path: `${COURSE_CONTENTS_PATH}/${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`,
loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule),
data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` },
},
],
() => CoreScreen.isMobile,
@ -82,10 +85,12 @@ const courseContentsRoutes: Routes = conditionalRoutes(
path: `${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`,
loadChildren: () => import('./pages/new-discussion/new-discussion.module')
.then(m => m.AddonForumNewDiscussionPageModule),
data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` },
},
{
path: `${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`,
loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule),
data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` },
},
],
() => CoreScreen.isTablet,

View File

@ -56,72 +56,74 @@
</core-context-menu>
</core-navbar-buttons>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!discussionLoaded" (ionRefresh)="doRefresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-swipe-navigation [manager]="discussions">
<ion-refresher slot="fixed" [disabled]="!discussionLoaded" (ionRefresh)="doRefresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="discussionLoaded">
<!-- Discussion replies found to be synchronized -->
<ion-card class="core-warning-card" *ngIf="postHasOffline || hasOfflineRatings">
<ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start" aria-hidden="true"></ion-icon>
<ion-label>{{ 'core.hasdatatosync' | translate:{$a: discussionStr} }}</ion-label>
</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>
<core-loading [hideUntil]="discussionLoaded">
<!-- Discussion replies found to be synchronized -->
<ion-card class="core-warning-card" *ngIf="postHasOffline || hasOfflineRatings">
<ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start" aria-hidden="true"></ion-icon>
<ion-label>{{ 'core.hasdatatosync' | translate:{$a: discussionStr} }}</ion-label>
</ion-item>
</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>
<!-- 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>
</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>

View File

@ -14,6 +14,8 @@
import { ContextLevel, CoreConstants } from '@/core/constants';
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 { CoreFileUploader } from '@features/fileuploader/services/fileuploader';
import { CoreRatingInfo, CoreRatingProvider } from '@features/rating/services/rating';
@ -32,6 +34,8 @@ import { Network, NgZone, Translate } from '@singletons';
import { CoreArray } from '@singletons/array';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { Subscription } from 'rxjs';
import { AddonModForumDiscussionsSource } from '../../classes/forum-discussions-source';
import { AddonModForumDiscussionsSwipeManager } from '../../classes/forum-discussions-swipe-manager';
import {
AddonModForum,
AddonModForumAccessInformation,
@ -68,6 +72,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
forum: Partial<AddonModForumData> = {};
accessInfo: AddonModForumAccessInformation = {};
discussion?: AddonModForumDiscussion;
discussions?: AddonModForumDiscussionDiscussionsSwipeManager;
startingPost?: Post;
posts!: Post[];
discussionLoaded = false;
@ -117,14 +122,16 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
constructor(
@Optional() protected splitView: CoreSplitViewComponent,
protected elementRef: ElementRef,
protected route: ActivatedRoute,
) {}
get isMobile(): boolean {
return CoreScreen.isMobile;
}
ngOnInit(): void {
async ngOnInit(): Promise<void> {
try {
const routeData = this.route.snapshot.data;
this.courseId = CoreNavigator.getRouteNumberParam('courseId');
this.cmId = CoreNavigator.getRouteNumberParam('cmId');
this.forumId = CoreNavigator.getRouteNumberParam('forumId');
@ -136,6 +143,16 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
this.postId = CoreNavigator.getRouteNumberParam('postId');
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) {
CoreDomUtils.showErrorModal(error);
@ -311,6 +328,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
*/
ngOnDestroy(): void {
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.
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;
}
}

View File

@ -12,76 +12,77 @@
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!groupsLoaded" (ionRefresh)="refreshGroups($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="groupsLoaded">
<form *ngIf="showForm" #newDiscFormEl>
<ion-item>
<ion-label position="stacked">{{ 'addon.mod_forum.subject' | translate }}</ion-label>
<ion-input [(ngModel)]="newDiscussion.subject" type="text" [placeholder]="'addon.mod_forum.subject' | translate"
name="subject">
</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>
<core-swipe-navigation [manager]="discussions">
<ion-refresher slot="fixed" [disabled]="!groupsLoaded" (ionRefresh)="refreshGroups($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="groupsLoaded">
<form *ngIf="showForm" #newDiscFormEl>
<ion-item>
<ion-label position="stacked">{{ 'addon.mod_forum.subject' | translate }}</ion-label>
<ion-input [(ngModel)]="newDiscussion.subject" type="text" [placeholder]="'addon.mod_forum.subject' | translate"
name="subject">
</ion-input>
</ion-item>
<ion-item>
<ion-label>{{ 'addon.mod_forum.discussionsubscription' | translate }}</ion-label>
<ion-toggle [(ngModel)]="newDiscussion.subscribe" name="subscribe"></ion-toggle>
<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 *ngIf="canPin">
<ion-label>{{ 'addon.mod_forum.discussionpinned' | translate }}</ion-label>
<ion-toggle [(ngModel)]="newDiscussion.pin" name="pin"></ion-toggle>
<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>
<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>
<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-label>{{ 'addon.mod_forum.discussionsubscription' | translate }}</ion-label>
<ion-toggle [(ngModel)]="newDiscussion.subscribe" name="subscribe"></ion-toggle>
</ion-item>
<ion-item *ngIf="canPin">
<ion-label>{{ 'addon.mod_forum.discussionpinned' | translate }}</ion-label>
<ion-toggle [(ngModel)]="newDiscussion.pin" name="pin"></ion-toggle>
</ion-item>
<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>

View File

@ -40,6 +40,10 @@ import { CoreTextUtils } from '@services/utils/text';
import { CanLeave } from '@guards/can-leave';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
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 = {
subject: string;
@ -88,6 +92,8 @@ export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLea
accessInfo: AddonModForumAccessInformation = {};
courseId!: number;
discussions?: AddonModForumNewDiscussionDiscussionsSwipeManager;
protected cmId!: number;
protected forumId!: number;
protected timeCreated!: number;
@ -97,17 +103,29 @@ export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLea
protected originalData?: Partial<NewDiscussionData>;
protected forceLeave = false;
constructor(@Optional() protected splitView: CoreSplitViewComponent) {}
constructor(protected route: ActivatedRoute, @Optional() protected splitView: CoreSplitViewComponent) {}
/**
* Component being initialized.
*/
ngOnInit(): void {
async ngOnInit(): Promise<void> {
try {
const routeData = this.route.snapshot.data;
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
this.forumId = CoreNavigator.getRequiredRouteNumberParam('forumId');
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) {
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}`;
}
}

View 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';

View File

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

View File

@ -54,7 +54,7 @@
[component]="component" [componentId]="componentId" [courseId]="courseId" [hasDataToSync]="hasOffline || hasOfflineRatings">
</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-label>
<h2>{{ 'addon.mod_glossary.entriestobesynced' | translate }}</h2>
@ -70,7 +70,7 @@
</ion-item>
</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">
<ion-item-divider *ngIf="getDivider && showDivider(entry, entries.onlineEntries[index - 1])">
<ion-label>
@ -88,11 +88,11 @@
</ng-container>
</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">
</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-loading>

View File

@ -14,8 +14,9 @@
import { ContextLevel } from '@/core/constants';
import { AfterViewInit, Component, OnDestroy, OnInit, Optional, ViewChild } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { CorePageItemsListManager } from '@classes/page-items-list-manager';
import { ActivatedRoute } from '@angular/router';
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 { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component';
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 { Translate } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import {
AddonModGlossaryEntriesSource,
AddonModGlossaryEntryItem,
AddonModGlossaryFetchMode,
} from '../../classes/glossary-entries-source';
import {
AddonModGlossary,
AddonModGlossaryEntry,
AddonModGlossaryEntryWithCategory,
AddonModGlossaryGetEntriesOptions,
AddonModGlossaryGetEntriesWSResponse,
AddonModGlossaryGlossary,
AddonModGlossaryProvider,
} from '../../services/glossary';
import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from '../../services/glossary-offline';
import { AddonModGlossaryOfflineEntry } from '../../services/glossary-offline';
import {
AddonModGlossaryAutoSyncData,
AddonModGlossarySyncProvider,
@ -63,23 +67,17 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
component = AddonModGlossaryProvider.COMPONENT;
moduleName = 'glossary';
isSearch = false;
hasSearched = false;
canAdd = false;
loadMoreError = false;
loadingMessage?: string;
entries: AddonModGlossaryEntriesManager;
loadingMessage: string;
entries!: AddonModGlossaryEntriesManager;
hasOfflineRatings = false;
glossary?: AddonModGlossaryGlossary;
protected syncEventName = AddonModGlossarySyncProvider.AUTO_SYNCED;
protected fetchFunction?: (options?: AddonModGlossaryGetEntriesOptions) => AddonModGlossaryGetEntriesWSResponse;
protected fetchInvalidate?: () => Promise<void>;
protected addEntryObserver?: CoreEventObserver;
protected fetchMode?: AddonModGlossaryFetchMode;
protected viewMode?: string;
protected fetchedEntriesCanLoadMore = false;
protected fetchedEntries: AddonModGlossaryEntry[] = [];
protected sourceUnsubscribe?: () => void;
protected ratingOfflineObserver?: CoreEventObserver;
protected ratingSyncObserver?: CoreEventObserver;
@ -87,26 +85,47 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
showDivider: (entry: AddonModGlossaryEntry, previous?: AddonModGlossaryEntry) => boolean = () => false;
constructor(
route: ActivatedRoute,
protected route: ActivatedRoute,
protected content?: IonContent,
@Optional() courseContentsPage?: CoreCourseContentsPage,
@Optional() protected courseContentsPage?: CoreCourseContentsPage,
) {
super('AddonModGlossaryIndexComponent', content, courseContentsPage);
this.entries = new AddonModGlossaryEntriesManager(
route.component,
this,
courseContentsPage ? `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` : '',
);
this.loadingMessage = Translate.instant('core.loading');
}
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
*/
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.
this.addEntryObserver = CoreEvents.on(AddonModGlossaryProvider.ADD_ENTRY_EVENT, (data) => {
@ -143,11 +162,9 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
return;
}
this.entries.start(this.splitView);
await this.entries.start(this.splitView);
try {
await AddonModGlossary.logView(this.glossary.id, this.viewMode!, this.glossary.name);
CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata);
} catch (error) {
// 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> {
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.canAdd = !!this.glossary.canaddentry || false;
this.dataRetrieved.emit(this.glossary);
if (!this.fetchMode) {
if (!this.entries.getSource().fetchMode) {
this.switchMode('letter_all');
}
@ -177,7 +198,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
const [hasOfflineRatings] = await Promise.all([
CoreRatingOffline.hasRatings('mod_glossary', 'entry', ContextLevel.MODULE, this.glossary.coursemodule),
this.fetchEntries(),
refresh ? this.entries.reload() : this.entries.load(),
]);
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
*/
protected async invalidateContent(): Promise<void> {
const promises: Promise<void>[] = [];
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);
await this.entries.getSource().invalidateCache();
}
/**
@ -277,109 +250,50 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
* @param mode New mode.
*/
protected switchMode(mode: AddonModGlossaryFetchMode): void {
this.fetchMode = mode;
this.isSearch = false;
this.entries.getSource().switchMode(mode);
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',
);
this.getDivider = (entry) => entry.userfullname;
this.showDivider = (entry, previous) => !previous || entry.userid != previous.userid;
break;
case 'cat_all':
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,
);
this.getDivider = (entry: AddonModGlossaryEntryWithCategory) => entry.categoryname || '';
this.showDivider = (entry, previous) => !previous || this.getDivider!(entry) != this.getDivider!(previous);
const getDivider = (entry: AddonModGlossaryEntryWithCategory) => entry.categoryname || '';
this.getDivider = getDivider;
this.showDivider = (entry, previous) => !previous || getDivider(entry) != getDivider(previous);
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',
);
this.getDivider = undefined;
this.showDivider = () => false;
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',
);
this.getDivider = undefined;
this.showDivider = () => false;
break;
case 'letter_all':
default:
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',
);
this.getDivider = (entry) => {
const getDivider = (entry) => {
// Try to get the first letter without HTML tags.
const noTags = CoreTextUtils.cleanTags(entry.concept);
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;
}
}
}
@ -391,7 +305,9 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
*/
async loadMoreEntries(infiniteComplete?: () => void): Promise<void> {
try {
await this.fetchEntries(true);
this.loadMoreError = false;
await this.entries.load();
} catch (error) {
this.loadMoreError = true;
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentries', true);
@ -406,21 +322,34 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
* @param event Event.
*/
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,
componentProps: {
browseModes: this.glossary!.browsemodes,
selectedMode: this.isSearch ? '' : this.fetchMode,
browseModes: this.glossary.browsemodes,
selectedMode: this.isSearch ? '' : previousMode,
},
event,
});
if (mode) {
if (mode !== this.fetchMode) {
this.changeFetchMode(mode);
} else if (this.isSearch) {
this.toggleSearch();
}
if (!newMode) {
return;
}
if (newMode !== previousMode) {
this.changeFetchMode(newMode);
return;
}
if (this.isSearch) {
this.toggleSearch();
return;
}
}
@ -429,20 +358,22 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
*/
toggleSearch(): void {
if (this.isSearch) {
this.isSearch = false;
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;
const fetchMode = this.entries.getSource().fetchMode;
this.fetchedEntries = this.entries.onlineEntries;
this.fetchedEntriesCanLoadMore = !this.entries.completed;
this.entries.setItems([], false);
fetchMode && this.switchMode(fetchMode);
this.entries.getSource().stopSearch(this.fetchedEntries, this.fetchedEntriesCanLoadMore);
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.
*/
changeFetchMode(mode: AddonModGlossaryFetchMode): void {
this.isSearch = false;
this.loadingMessage = Translate.instant('core.loading');
this.content?.scrollToTop();
this.switchMode(mode);
@ -463,7 +393,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
* Opens new entry editor.
*/
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 {
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.hasSearched = true;
this.entries.getSource().search(query);
this.loadContent();
}
@ -503,154 +418,44 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity
this.addEntryObserver?.off();
this.ratingOfflineObserver?.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.
*/
class AddonModGlossaryEntriesManager extends CorePageItemsListManager<EntryItem> {
class AddonModGlossaryEntriesManager extends CoreListItemsManager<AddonModGlossaryEntryItem, AddonModGlossaryEntriesSource> {
onlineEntries: AddonModGlossaryEntry[] = [];
offlineEntries: AddonModGlossaryOfflineEntry[] = [];
protected glossaryPathPrefix: string;
protected component: AddonModGlossaryIndexComponent;
constructor(
pageComponent: unknown,
component: AddonModGlossaryIndexComponent,
glossaryPathPrefix: string,
) {
super(pageComponent);
this.component = component;
this.glossaryPathPrefix = glossaryPathPrefix;
get offlineEntries(): AddonModGlossaryOfflineEntry[] {
return this.getSource().offlineEntries;
}
/**
* Type guard to infer NewEntryForm objects.
*
* @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);
get onlineEntries(): AddonModGlossaryEntry[] {
return this.getSource().onlineEntries;
}
/**
* @inheritdoc
*/
setItems(entries: EntryItem[], hasMoreItems: boolean = false): void {
super.setItems(entries, hasMoreItems);
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);
}
});
protected getDefaultItem(): AddonModGlossaryEntryItem | null {
return this.getSource().onlineEntries[0] || null;
}
/**
* @inheritdoc
*/
resetItems(): void {
super.resetItems();
this.onlineEntries = [];
this.offlineEntries = [];
}
protected async logActivity(): Promise<void> {
const glossary = this.getSource().glossary;
const viewMode = this.getSource().viewMode;
/**
* @inheritdoc
*/
protected getItemPath(entry: EntryItem): string {
if (this.isOnlineEntry(entry)) {
return `${this.glossaryPathPrefix}entry/${entry.id}`;
if (!glossary || !viewMode) {
return;
}
if (this.isOfflineEntry(entry)) {
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;
await AddonModGlossary.logView(glossary.id, viewMode, glossary.name);
}
}
export type AddonModGlossaryFetchMode = 'author_all' | 'cat_all' | 'newest_first' | 'recently_updated' | 'letter_all';

View File

@ -14,7 +14,7 @@
import { Component, Input, OnInit } from '@angular/core';
import { PopoverController } from '@singletons';
import { AddonModGlossaryFetchMode } from '../index';
import { AddonModGlossaryFetchMode } from '../../classes/glossary-entries-source';
/**
* Component to display the mode picker.

View File

@ -51,10 +51,12 @@ const mainMenuRoutes: Routes = [
{
path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`,
loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule),
data: { swipeEnabled: false },
},
{
path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`,
loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule),
data: { swipeEnabled: false },
},
{
path: AddonModGlossaryModuleHandlerService.PAGE_NAME,
@ -65,10 +67,12 @@ const mainMenuRoutes: Routes = [
{
path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`,
loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
},
{
path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`,
loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
},
],
() => CoreScreen.isMobile,
@ -80,10 +84,12 @@ const courseContentsRoutes: Routes = conditionalRoutes(
{
path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`,
loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
},
{
path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`,
loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule),
data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` },
},
],
() => CoreScreen.isTablet,

View File

@ -12,72 +12,75 @@
</ion-toolbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="loaded">
<form #editFormEl *ngIf="glossary">
<ion-item>
<ion-label position="stacked">{{ 'addon.mod_glossary.concept' | translate }}</ion-label>
<ion-input type="text" [placeholder]="'addon.mod_glossary.concept' | translate" [(ngModel)]="entry.concept" name="concept">
</ion-input>
</ion-item>
<ion-item>
<ion-label position="stacked">{{ 'addon.mod_glossary.definition' | translate }}</ion-label>
<core-rich-text-editor [control]="definitionControl" (contentChanged)="onDefinitionChange($event)"
[placeholder]="'addon.mod_glossary.definition' | translate" name="addon_mod_glossary_edit" [component]="component"
[componentId]="cmId" [autoSave]="true" contextLevel="module" [contextInstanceId]="cmId" elementId="definition_editor"
[draftExtraParams]="editorExtraParams">
</core-rich-text-editor>
</ion-item>
<ion-item *ngIf="categories.length > 0">
<ion-label position="stacked" id="addon-mod-glossary-categories-label">
{{ 'addon.mod_glossary.categories' | translate }}
</ion-label>
<ion-select [(ngModel)]="options.categories" multiple="true" aria-labelledby="addon-mod-glossary-categories-label"
interface="action-sheet" [placeholder]="'addon.mod_glossary.categories' | translate" name="categories"
[interfaceOptions]="{header: 'addon.mod_glossary.categories' | translate}">
<ion-select-option *ngFor="let category of categories" [value]="category.id">
{{ category.name }}
</ion-select-option>
</ion-select>
</ion-item>
<ion-item>
<ion-label position="stacked" id="addon-mod-glossary-aliases-label">
{{ 'addon.mod_glossary.aliases' | translate }}
</ion-label>
<ion-textarea [(ngModel)]="options.aliases" rows="1" [core-auto-rows]="options.aliases"
aria-labelledby="addon-mod-glossary-aliases-label" name="aliases">
</ion-textarea>
</ion-item>
<ion-item-divider>
<ion-label>
<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">
<core-swipe-navigation [manager]="entries">
<core-loading [hideUntil]="loaded">
<form #editFormEl *ngIf="glossary">
<ion-item>
<ion-label position="stacked">{{ 'addon.mod_glossary.concept' | translate }}</ion-label>
<ion-input type="text" [placeholder]="'addon.mod_glossary.concept' | translate" [(ngModel)]="entry.concept"
name="concept">
</ion-input>
</ion-item>
<ion-item>
<ion-label position="stacked">{{ 'addon.mod_glossary.definition' | translate }}</ion-label>
<core-rich-text-editor [control]="definitionControl" (contentChanged)="onDefinitionChange($event)"
[placeholder]="'addon.mod_glossary.definition' | translate" name="addon_mod_glossary_edit" [component]="component"
[componentId]="cmId" [autoSave]="true" contextLevel="module" [contextInstanceId]="cmId"
elementId="definition_editor" [draftExtraParams]="editorExtraParams">
</core-rich-text-editor>
</ion-item>
<ion-item *ngIf="categories.length > 0">
<ion-label position="stacked" id="addon-mod-glossary-categories-label">
{{ 'addon.mod_glossary.categories' | translate }}
</ion-label>
<ion-select [(ngModel)]="options.categories" multiple="true" aria-labelledby="addon-mod-glossary-categories-label"
interface="action-sheet" [placeholder]="'addon.mod_glossary.categories' | translate" name="categories"
[interfaceOptions]="{header: 'addon.mod_glossary.categories' | translate}">
<ion-select-option *ngFor="let category of categories" [value]="category.id">
{{ category.name }}
</ion-select-option>
</ion-select>
</ion-item>
<ion-item>
<ion-label position="stacked" id="addon-mod-glossary-aliases-label">
{{ 'addon.mod_glossary.aliases' | translate }}
</ion-label>
<ion-textarea [(ngModel)]="options.aliases" rows="1" [core-auto-rows]="options.aliases"
aria-labelledby="addon-mod-glossary-aliases-label" name="aliases">
</ion-textarea>
</ion-item>
<ion-item-divider>
<ion-label>
<h2>{{ 'addon.mod_glossary.linking' | translate }}</h2>
<h2>{{ 'addon.mod_glossary.attachment' | translate }}</h2>
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'addon.mod_glossary.entryusedynalink' | translate }}</ion-label>
<ion-toggle [(ngModel)]="options.usedynalink" name="usedynalink"></ion-toggle>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'addon.mod_glossary.casesensitive' | translate }}</ion-label>
<ion-toggle [disabled]="!options.usedynalink" [(ngModel)]="options.casesensitive" name="casesensitive">
</ion-toggle>
</ion-item>
<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-attachments [files]="attachments" [component]="component" [componentId]="glossary.coursemodule" [allowOffline]="true"
[courseId]="courseId">
</core-attachments>
<ng-container *ngIf="glossary.usedynalink">
<ion-item-divider>
<ion-label>
<h2>{{ 'addon.mod_glossary.linking' | translate }}</h2>
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'addon.mod_glossary.entryusedynalink' | translate }}</ion-label>
<ion-toggle [(ngModel)]="options.usedynalink" name="usedynalink"></ion-toggle>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>{{ 'addon.mod_glossary.casesensitive' | translate }}</ion-label>
<ion-toggle [disabled]="!options.usedynalink" [(ngModel)]="options.casesensitive" name="casesensitive">
</ion-toggle>
</ion-item>
<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>

View File

@ -12,9 +12,11 @@
// See the License for the specific language governing permissions and
// 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 { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router';
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 { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
import { CanLeave } from '@guards/can-leave';
@ -26,6 +28,8 @@ import { CoreTextUtils } from '@services/utils/text';
import { Translate } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreForms } from '@singletons/form';
import { AddonModGlossaryEntriesSource } from '../../classes/glossary-entries-source';
import { AddonModGlossaryEntriesSwipeManager } from '../../classes/glossary-entries-swipe-manager';
import {
AddonModGlossary,
AddonModGlossaryCategory,
@ -45,7 +49,7 @@ import { AddonModGlossaryOffline } from '../../services/glossary-offline';
selector: 'page-addon-mod-glossary-edit',
templateUrl: 'edit.html',
})
export class AddonModGlossaryEditPage implements OnInit, CanLeave {
export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave {
@ViewChild('editFormEl') formElement?: ElementRef;
@ -64,6 +68,8 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
timecreated: 0,
};
entries?: AddonModGlossaryEditEntriesSwipeManager;
options = {
categories: <string[]> [],
aliases: '',
@ -80,18 +86,30 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
protected originalData?: AddonModGlossaryNewEntryWithFiles;
protected saved = false;
constructor(@Optional() protected splitView: CoreSplitViewComponent) {}
constructor(protected route: ActivatedRoute, @Optional() protected splitView: CoreSplitViewComponent) {}
/**
* Component being initialized.
*/
ngOnInit(): void {
async ngOnInit(): Promise<void> {
try {
const routeData = this.route.snapshot.data;
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.timecreated = CoreNavigator.getRequiredRouteNumberParam('timecreated');
this.concept = CoreNavigator.getRouteParam<string>('concept') || '';
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) {
CoreDomUtils.showErrorModal(error);
@ -103,6 +121,13 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
this.fetchData();
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.entries?.destroy();
}
/**
* Fetch required data.
*
@ -134,7 +159,11 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
* @return Promise resolved when done.
*/
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.definition = entry.definition || '';
@ -159,7 +188,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
// Treat offline attachments if any.
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();
}
@ -236,6 +265,10 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
definition = CoreTextUtils.formatHtmlLines(definition);
try {
if (!this.glossary) {
return;
}
// Upload attachments first if any.
const { saveOffline, attachmentsResult } = await this.uploadAttachments(timecreated);
@ -244,7 +277,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
categories: this.options.categories.join(','),
};
if (this.glossary!.usedynalink) {
if (this.glossary.usedynalink) {
options.usedynalink = this.options.usedynalink ? 1 : 0;
if (this.options.usedynalink) {
options.casesensitive = this.options.casesensitive ? 1 : 0;
@ -253,9 +286,9 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
}
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.
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,
cmId: this.cmId,
});
@ -268,7 +301,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
// Save entry in offline.
await AddonModGlossaryOffline.addNewEntry(
this.glossary!.id,
this.glossary.id,
this.entry.concept,
definition,
this.courseId,
@ -283,7 +316,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
// Try to send it to server.
// Don't allow offline if there are attachments since they were uploaded fine.
await AddonModGlossary.addEntry(
this.glossary!.id,
this.glossary.id,
this.entry.concept,
definition,
this.courseId,
@ -293,7 +326,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
timeCreated: timecreated,
discardEntry: this.entry,
allowOffline: !this.attachments.length,
checkDuplicates: !this.glossary!.allowduplicatedentries,
checkDuplicates: !this.glossary.allowduplicatedentries,
},
);
}
@ -303,12 +336,12 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
if (entryId) {
// 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(AddonModGlossaryProvider.ADD_ENTRY_EVENT, {
glossaryId: this.glossary!.id,
glossaryId: this.glossary.id,
entryId: entryId,
}, CoreSites.getCurrentSiteId());
@ -342,7 +375,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
protected async uploadAttachments(
timecreated: number,
): Promise<{saveOffline: boolean; attachmentsResult?: number | CoreFileUploaderStoreFilesResult}> {
if (!this.attachments.length) {
if (!this.attachments.length || !this.glossary) {
return {
saveOffline: false,
};
@ -352,7 +385,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
const attachmentsResult = await CoreFileUploader.uploadOrReuploadFiles(
this.attachments,
AddonModGlossaryProvider.COMPONENT,
this.glossary!.id,
this.glossary.id,
);
return {
@ -362,7 +395,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave {
} catch {
// Cannot upload them in online, save them in offline.
const attachmentsResult = await AddonModGlossaryHelper.storeFiles(
this.glossary!.id,
this.glossary.id,
this.entry.concept,
timecreated,
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}`;
}
}

View File

@ -12,73 +12,75 @@
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="doRefresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-swipe-navigation [manager]="entries">
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="doRefresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ng-container *ngIf="entry && loaded">
<ion-item class="ion-text-wrap" *ngIf="showAuthor">
<core-user-avatar [user]="entry" slot="start"></core-user-avatar>
<ion-label>
<h2>
<core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId"
[courseId]="courseId">
<core-loading [hideUntil]="loaded">
<ng-container *ngIf="entry && loaded">
<ion-item class="ion-text-wrap" *ngIf="showAuthor">
<core-user-avatar [user]="entry" slot="start"></core-user-avatar>
<ion-label>
<h2>
<core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId"
[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>
</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>
</ion-label>
</ion-item>
<div *ngIf="entry.attachment" lines="none">
<core-file *ngFor="let file of entry.attachments" [file]="file" [component]="component" [componentId]="componentId">
</core-file>
</div>
<ion-item class="ion-text-wrap" *ngIf="tagsEnabled && entry && entry.tags && entry.tags.length > 0">
<ion-label>
<div slot="start">{{ 'core.tag.tags' | translate }}:</div>
<core-tag-list [tags]="entry.tags"></core-tag-list>
</ion-label>
</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-label>
</ion-item>
<div *ngIf="entry.attachment" lines="none">
<core-file *ngFor="let file of entry.attachments" [file]="file" [component]="component" [componentId]="componentId">
</core-file>
</div>
<ion-item class="ion-text-wrap" *ngIf="tagsEnabled && entry && entry.tags && entry.tags.length > 0">
<ion-label>
<div slot="start">{{ 'core.tag.tags' | translate }}:</div>
<core-tag-list [tags]="entry.tags"></core-tag-list>
</ion-label>
</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-item>
<ion-label>{{ 'addon.mod_glossary.errorloadingentry' | translate }}</ion-label>
</ion-item>
</ion-card>
</core-loading>
<ion-card *ngIf="!entry" class="core-warning-card">
<ion-item>
<ion-label>{{ 'addon.mod_glossary.errorloadingentry' | translate }}</ion-label>
</ion-item>
</ion-card>
</core-loading>
</core-swipe-navigation>
</ion-content>

View File

@ -12,7 +12,9 @@
// See the License for the specific language governing permissions and
// 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 { CoreComments } from '@features/comments/services/comments';
import { CoreRatingInfo } from '@features/rating/services/rating';
@ -21,6 +23,8 @@ import { IonRefresher } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { AddonModGlossaryEntriesSource } from '../../classes/glossary-entries-source';
import { AddonModGlossaryEntriesSwipeManager } from '../../classes/glossary-entries-swipe-manager';
import {
AddonModGlossary,
AddonModGlossaryEntry,
@ -35,13 +39,14 @@ import {
selector: 'page-addon-mod-glossary-entry',
templateUrl: 'entry.html',
})
export class AddonModGlossaryEntryPage implements OnInit {
export class AddonModGlossaryEntryPage implements OnInit, OnDestroy {
@ViewChild(CoreCommentsCommentsComponent) comments?: CoreCommentsCommentsComponent;
component = AddonModGlossaryProvider.COMPONENT;
componentId?: number;
entry?: AddonModGlossaryEntry;
entries?: AddonModGlossaryEntryEntriesSwipeManager;
glossary?: AddonModGlossaryGlossary;
loaded = false;
showAuthor = false;
@ -53,15 +58,30 @@ export class AddonModGlossaryEntryPage implements OnInit {
protected entryId!: number;
constructor(protected route: ActivatedRoute) {}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
try {
const routeData = this.route.snapshot.data;
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.entryId = CoreNavigator.getRequiredRouteNumberParam('entryId');
this.tagsEnabled = CoreTag.areTagsAvailableInSite();
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) {
CoreDomUtils.showErrorModal(error);
@ -73,16 +93,23 @@ export class AddonModGlossaryEntryPage implements OnInit {
try {
await this.fetchEntry();
if (!this.glossary) {
if (!this.glossary || !this.componentId) {
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 {
this.loaded = true;
}
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.entries?.destroy();
}
/**
* 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}`;
}
}

View File

@ -12,12 +12,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Params } from '@angular/router';
/**
* Updates listener.
*/
export interface CoreItemsListSourceListener<Item> {
onItemsUpdated(items: Item[], hasMoreItems: boolean): void;
onReset(): void;
onItemsUpdated?(items: Item[], hasMoreItems: boolean): void;
onReset?(): void;
}
/**
@ -35,9 +37,10 @@ export abstract class CoreItemsManagerSource<Item = unknown> {
return args.map(argument => String(argument)).join('-');
}
private items: Item[] | null = null;
private hasMoreItems = true;
private listeners: CoreItemsListSourceListener<Item>[] = [];
protected items: Item[] | null = null;
protected hasMoreItems = true;
protected listeners: CoreItemsListSourceListener<Item>[] = [];
protected dirty = false;
/**
* Check whether any page has been loaded.
@ -57,6 +60,17 @@ export abstract class CoreItemsManagerSource<Item = unknown> {
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.
*
@ -76,7 +90,12 @@ export abstract class CoreItemsManagerSource<Item = unknown> {
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 {
this.items = null;
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> {
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) {
return;
}
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.
*
* @param page Page number (starting at 0).
* @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.
*
* @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.
@ -163,7 +214,7 @@ export abstract class CoreItemsManagerSource<Item = unknown> {
this.items = items;
this.hasMoreItems = hasMoreItems;
this.listeners.forEach(listener => listener.onItemsUpdated(items, hasMoreItems));
this.listeners.forEach(listener => listener.onItemsUpdated?.call(listener, items, hasMoreItems));
}
}

View File

@ -18,6 +18,7 @@ type SourceConstructor<T extends CoreItemsManagerSource = CoreItemsManagerSource
getSourceId(...args: unknown[]): string;
new (...args: unknown[]): T;
};
type SourceConstuctorInstance<T> = T extends { new(...args: unknown[]): infer P } ? P : never;
type InstanceTracking = { instance: CoreItemsManagerSource; references: unknown[] };
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.
* @returns Source.
*/
static getOrCreateSource<T extends CoreItemsManagerSource>(
constructor: SourceConstructor<T>,
constructorArguments: ConstructorParameters<SourceConstructor<T>>,
): T {
static getOrCreateSource<T extends CoreItemsManagerSource, C extends SourceConstructor<T>>(
constructor: C,
constructorArguments: ConstructorParameters<C>,
): SourceConstuctorInstance<C> {
const id = constructor.getSourceId(...constructorArguments);
const constructorInstances = this.getConstructorInstances(constructor);
return constructorInstances[id]?.instance as T
return constructorInstances[id]?.instance as SourceConstuctorInstance<C>
?? this.createInstance(id, constructor, constructorArguments);
}
@ -57,7 +58,7 @@ export class CoreItemsManagerSourcesTracker {
const constructorInstances = this.getConstructorInstances(source.constructor as SourceConstructor);
const instanceId = this.instanceIds.get(source);
if (!instanceId) {
if (instanceId === undefined) {
return;
}
@ -82,7 +83,7 @@ export class CoreItemsManagerSourcesTracker {
const instanceId = this.instanceIds.get(source);
const index = constructorInstances?.[instanceId ?? '']?.references.indexOf(reference) ?? -1;
if (!constructorInstances || !instanceId || index === -1) {
if (!constructorInstances || instanceId === undefined || index === -1) {
return;
}

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { ActivatedRoute, ActivatedRouteSnapshot, Params } from '@angular/router';
import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router';
import { CoreNavigationOptions, CoreNavigator } from '@services/navigator';
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.
*/
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 selectedItem: Item | null = null;
constructor(source: CoreItemsManagerSource<Item>) {
constructor(source: Source) {
this.setSource(source);
}
@ -36,7 +36,7 @@ export abstract class CoreItemsManager<Item = unknown> {
*
* @returns Source.
*/
getSource(): CoreItemsManagerSource<Item> {
getSource(): Source {
if (!this.source) {
throw new Error('Source is missing from items manager');
}
@ -49,7 +49,7 @@ export abstract class CoreItemsManager<Item = unknown> {
*
* @param newSource New source.
*/
setSource(newSource: CoreItemsManagerSource<Item> | null): void {
setSource(newSource: Source | null): void {
if (this.source) {
CoreItemsManagerSourcesTracker.removeReference(this.source.instance, this);
@ -92,31 +92,26 @@ export abstract class CoreItemsManager<Item = unknown> {
*/
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.
*
* @param route Page 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.
* @return Query parameters to use when navigating to the item page.
* @param route Page route, if any.
* @return Path of the selected item.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected getItemQueryParams(item: Item): Params {
return {};
protected getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null {
if (!route) {
return null;
}
return this.getSelectedItemPathFromRoute(route);
}
/**
@ -152,7 +147,7 @@ export abstract class CoreItemsManager<Item = unknown> {
}
// 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);
if (selectedItemPath === itemPath) {
@ -160,7 +155,7 @@ export abstract class CoreItemsManager<Item = unknown> {
}
// Navigate to item.
const params = this.getItemQueryParams(item);
const params = this.getSource().getItemQueryParams(item);
const pathPrefix = selectedItemPath ? selectedItemPath.split('/').fill('../').join('') : '';
await CoreNavigator.navigate(pathPrefix + itemPath, { params, ...options });
@ -173,7 +168,7 @@ export abstract class CoreItemsManager<Item = unknown> {
*/
protected onSourceItemsUpdated(items: Item[]): void {
this.itemsMap = items.reduce((map, item) => {
map[this.getItemPath(item)] = item;
map[this.getSource().getItemPath(item)] = item;
return map;
}, {});

View File

@ -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.
*/
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 splitView?: CoreSplitViewComponent;
protected splitViewOutletSubscription?: Subscription;
constructor(source: CoreItemsManagerSource<Item>, pageRouteLocator: unknown | ActivatedRoute) {
constructor(source: Source, pageRouteLocator: unknown | ActivatedRoute) {
super(source);
this.pageRouteLocator = pageRouteLocator;
@ -67,15 +70,6 @@ export abstract class CoreListItemsManager<Item = unknown> extends CoreItemsMana
// Calculate current selected item.
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.
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> {
await this.getSource().loadNextPage();
async load(): Promise<void> {
await this.getSource().load();
}
/**
@ -172,6 +166,25 @@ export abstract class CoreListItemsManager<Item = unknown> extends CoreItemsMana
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.
*/
@ -193,10 +206,12 @@ export abstract class CoreListItemsManager<Item = unknown> extends CoreItemsMana
/**
* @inheritdoc
*/
protected getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null {
protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null {
const segments: UrlSegment[] = [];
while ((route = route?.firstChild)) {
while (route.firstChild) {
route = route.firstChild;
segments.push(...route.url);
}

View File

@ -12,16 +12,21 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, ActivatedRouteSnapshot, UrlSegment } from '@angular/router';
import { CoreNavigator } from '@services/navigator';
import { CoreItemsManager } from './items-manager';
import { CoreItemsManagerSource } from './items-manager-source';
/**
* 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.
@ -51,6 +56,25 @@ export abstract class CoreSwipeItemsManager<Item = unknown> extends CoreItemsMan
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.
*
@ -86,7 +110,7 @@ export abstract class CoreSwipeItemsManager<Item = unknown> extends CoreItemsMan
const item = items?.[index + delta] ?? null;
if (!item && !this.getSource().isCompleted()) {
await this.getSource().loadNextPage();
await this.getSource().load();
return this.getItemBy(delta);
}

View File

@ -1,4 +1,4 @@
<ion-slides [options]="{ allowTouchMove: !!manager }" (swipeleft)="swipeLeft()" (swiperight)="swipeRight()">
<ion-slides [options]="{ allowTouchMove: enabled }" (swipeleft)="swipeLeft()" (swiperight)="swipeRight()">
<ion-slide>
<ng-content></ng-content>
</ion-slide>

View File

@ -5,3 +5,15 @@ ion-slides {
ion-slide {
align-items: start;
}
::ng-deep {
core-loading .core-loading-content {
width: 100%;
}
ion-refresher.refresher-native {
z-index: 2;
}
}

View File

@ -14,6 +14,7 @@
import { Component, Input } from '@angular/core';
import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager';
import { CoreScreen } from '@services/screen';
@Component({
selector: 'core-swipe-navigation',
@ -24,10 +25,18 @@ export class CoreSwipeNavigationComponent {
@Input() manager?: CoreSwipeItemsManager;
get enabled(): boolean {
return CoreScreen.isMobile && !!this.manager;
}
/**
* Swipe to previous item.
*/
swipeLeft(): void {
if (!this.enabled) {
return;
}
this.manager?.navigateToPreviousItem();
}
@ -35,6 +44,10 @@ export class CoreSwipeNavigationComponent {
* Swipe to next item.
*/
swipeRight(): void {
if (!this.enabled) {
return;
}
this.manager?.navigateToNextItem();
}

View File

@ -12,6 +12,7 @@
// 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, CoreUserData, CoreUserParticipant, CoreUserProvider } from '../services/user';
@ -40,6 +41,20 @@ export class CoreUserParticipantsSource extends CoreItemsManagerSource<CoreUserP
this.SEARCH_QUERY = searchQuery;
}
/**
* @inheritdoc
*/
getItemPath(user: CoreUserParticipant | CoreUserData): string {
return user.id.toString();
}
/**
* @inheritdoc
*/
getItemQueryParams(): Params {
return { search: this.SEARCH_QUERY };
}
/**
* @inheritdoc
*/

View File

@ -13,7 +13,6 @@
// limitations under the License.
import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Params } from '@angular/router';
import { IonRefresher } from '@ionic/angular';
import { CoreApp } from '@services/app';
@ -50,7 +49,7 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.participants = new CoreUserParticipantsManager(
CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]),
this,
CoreUserParticipantsPage,
);
} catch (error) {
CoreDomUtils.showErrorModal(error);
@ -186,7 +185,7 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro
private async fetchParticipants(reload: boolean): Promise<void> {
reload
? await this.participants.reload()
: await this.participants.loadNextPage();
: await this.participants.load();
this.fetchMoreParticipantsFailed = false;
}
@ -196,35 +195,13 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro
/**
* Helper to manage the list of participants.
*/
class CoreUserParticipantsManager extends CoreListItemsManager<CoreUserParticipant | CoreUserData> {
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 };
}
class CoreUserParticipantsManager extends CoreListItemsManager<CoreUserParticipant | CoreUserData, CoreUserParticipantsSource> {
/**
* @inheritdoc
*/
protected async logActivity(): Promise<void> {
await CoreUser.logParticipantsView(this.page.courseId);
await CoreUser.logParticipantsView(this.getSource().COURSE_ID);
}
}

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// 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 { IonRefresher } from '@ionic/angular';
import { Subscription } from 'rxjs';
@ -21,7 +21,7 @@ import { CoreSite } from '@classes/site';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
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 { CoreUserDelegate, CoreUserDelegateService, CoreUserProfileHandlerData } from '@features/user/services/user-delegate';
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 { CoreUserParticipantsSource } from '@features/user/classes/participants-source';
import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker';
import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source';
@Component({
selector: 'page-core-user-profile',
@ -57,7 +56,6 @@ export class CoreUserProfilePage implements OnInit, OnDestroy {
communicationHandlers: CoreUserProfileHandlerData[] = [];
users?: CoreUserSwipeItemsManager;
usersQueryParams: Params = {};
constructor(private route: ActivatedRoute) {
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') {
const search = CoreNavigator.getRouteParam('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();
}
@ -227,38 +224,12 @@ export class CoreUserProfilePage implements OnInit, OnDestroy {
/**
* Helper to manage swiping within a collection of users.
*/
class CoreUserSwipeItemsManager extends CoreSwipeItemsManager<CoreUserBasicData> {
page: CoreUserProfilePage;
constructor(source: CoreItemsManagerSource<CoreUserBasicData>, page: CoreUserProfilePage) {
super(source);
this.page = page;
}
class CoreUserSwipeItemsManager extends CoreSwipeItemsManager {
/**
* @inheritdoc
*/
protected getItemPath(item: CoreUserBasicData): string {
return String(item.id);
}
/**
* @inheritdoc
*/
protected getItemQueryParams(): Params {
return this.page.usersQueryParams;
}
/**
* @inheritdoc
*/
protected getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null {
if (!route) {
return null;
}
protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null {
return route.params.userId;
}

View File

@ -19,9 +19,6 @@
right: calc(50% - 12px - var(--core-avatar-size) / 2) !important;
}
}
core-loading .core-loading-content {
width: 100%;
}
}
}

View File

@ -397,8 +397,14 @@ export class CoreUserDelegateService extends CoreDelegate<CoreUserProfileHandler
*/
protected clearHandlerCache(courseId?: number, userId?: number): void {
if (courseId && userId) {
const cacheKey = this.getCacheKey(courseId, userId);
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 {
this.enabledForUserCache = {};

View File

@ -290,7 +290,7 @@ export class CoreNavigatorService {
* @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.
*/
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
let value: any;