Merge pull request #2696 from crazyserver/MOBILE-3663

Mobile 3663
main
Dani Palou 2021-03-08 13:00:29 +01:00 committed by GitHub
commit b52cc6477b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1882 additions and 87 deletions

View File

@ -1,8 +1,5 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'addon.messages.groupinfo' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -1,8 +1,5 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ plugin.name }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -1,8 +1,5 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'addon.mod_book.toc' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -18,6 +18,7 @@ import { CoreCourseComponentsModule } from '@features/course/components/componen
import { CoreEditorComponentsModule } from '@features/editor/components/components.module';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreTagComponentsModule } from '@features/tag/components/components.module';
import { CoreRatingComponentsModule } from '@features/rating/components/components.module';
import { AddonModForumDiscussionOptionsMenuComponent } from './discussion-options-menu/discussion-options-menu';
import { AddonModForumEditPostComponent } from './edit-post/edit-post';
@ -40,6 +41,7 @@ import { AddonModForumSortOrderSelectorComponent } from './sort-order-selector/s
CoreCourseComponentsModule,
CoreTagComponentsModule,
CoreEditorComponentsModule,
CoreRatingComponentsModule,
],
exports: [
AddonModForumIndexComponent,

View File

@ -1,8 +1,5 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'addon.mod_forum.yourreply' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -50,6 +50,10 @@ import { CoreScreen } from '@services/screen';
import { CoreArray } from '@singletons/array';
import { AddonModForumPrefetchHandler } from '../../services/handlers/prefetch';
import { AddonModForumModuleHandlerService } from '../../services/handlers/module';
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';
/**
* Component that displays a forum entry page.
@ -88,9 +92,9 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
protected viewDiscObserver?: CoreEventObserver;
protected changeDiscObserver?: CoreEventObserver;
hasOfflineRatings?: boolean;
protected ratingOfflineObserver: any;
protected ratingSyncObserver: any;
hasOfflineRatings = false;
protected ratingOfflineObserver?: CoreEventObserver;
protected ratingSyncObserver?: CoreEventObserver;
constructor(
route: ActivatedRoute,
@ -166,7 +170,21 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
}
});
// @todo Listen for offline ratings saved and synced.
// Listen for offline ratings saved and synced.
this.ratingOfflineObserver = CoreEvents.on(CoreRatingProvider.RATING_SAVED_EVENT, (data) => {
if (this.forum && data.component == 'mod_forum' && data.ratingArea == 'post' &&
data.contextLevel == ContextLevel.MODULE && data.instanceId == this.forum.cmid) {
this.hasOfflineRatings = true;
}
});
this.ratingSyncObserver = CoreEvents.on(CoreRatingSyncProvider.SYNCED_EVENT, async (data) => {
if (this.forum && data.component == 'mod_forum' && data.ratingArea == 'post' &&
data.contextLevel == ContextLevel.MODULE && data.instanceId == this.forum.cmid) {
this.hasOfflineRatings =
await CoreRatingOffline.hasRatings('mod_forum', 'post', ContextLevel.MODULE, this.forum.cmid);
}
});
}
async ngAfterViewInit(): Promise<void> {
@ -224,7 +242,11 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
await Promise.all([
this.fetchOfflineDiscussions(),
this.fetchDiscussions(refresh),
// @todo fetch hasOfflineRatings.
CoreRatingOffline.hasRatings('mod_forum', 'post', ContextLevel.MODULE, this.forum!.cmid).then((hasRatings) => {
this.hasOfflineRatings = hasRatings;
return;
}),
]);
} catch (error) {
if (refresh) {

View File

@ -75,8 +75,7 @@
<core-tag-list [tags]="post.tags"></core-tag-list>
</ion-label>
</ion-item>
<!-- @todo -->
<!-- <core-rating-rate *ngIf="forum && ratingInfo"
<core-rating-rate *ngIf="forum && ratingInfo"
[ratingInfo]="ratingInfo" contextLevel="module" [instanceId]="componentId" [itemId]="post.id"
[itemSetId]="discussionId" [courseId]="courseId" [aggregateMethod]="forum.assessed" [scaleId]="forum.scale"
[userId]="post.author.id" (onUpdate)="ratingUpdated()">
@ -84,7 +83,7 @@
<core-rating-aggregate *ngIf="forum && ratingInfo"
[ratingInfo]="ratingInfo" contextLevel="module" [instanceId]="componentId" [itemId]="post.id"
[courseId]="courseId" [aggregateMethod]="forum.assessed" [scaleId]="forum.scale">
</core-rating-aggregate> -->
</core-rating-aggregate>
<ion-item *ngIf="post.id > 0 && post.capabilities.reply && !post.isprivatereply"
class="ion-no-padding ion-text-end addon-forum-reply-button">

View File

@ -52,6 +52,7 @@ import { AddonModForumOffline, AddonModForumReplyOptions } from '../../services/
import { CoreUtils } from '@services/utils/utils';
import { AddonModForumPostOptionsMenuComponent } from '../post-options-menu/post-options-menu';
import { AddonModForumEditPostComponent } from '../edit-post/edit-post';
import { CoreRatingInfo } from '@features/rating/services/rating';
/**
* Components that shows a discussion post, its attachments and the action buttons allowed (reply, etc.).
@ -75,7 +76,7 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy, OnChanges
@Input() forum!: AddonModForumData; // The forum the post belongs to. Required for attachments and offline posts.
@Input() accessInfo!: AddonModForumAccessInformation; // Forum access information.
@Input() parentSubject?: string; // Subject of parent post.
@Input() ratingInfo?: any; // TODO CoreRatingInfo; // Rating info item.
@Input() ratingInfo?: CoreRatingInfo; // Rating info item.
@Input() leavingPage?: boolean; // Whether the page that contains this post is being left and will be destroyed.
@Input() highlight = false;
@Output() onPostChange: EventEmitter<void> = new EventEmitter<void>(); // Event emitted when a reply is posted or modified.

View File

@ -1,8 +1,5 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'core.sort' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -12,9 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { ContextLevel } from '@/core/constants';
import { Component, OnDestroy, ViewChild, OnInit, AfterViewInit, ElementRef, Optional } from '@angular/core';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreFileUploader } from '@features/fileuploader/services/fileuploader';
import { CoreRatingInfo, CoreRatingProvider } from '@features/rating/services/rating';
import { CoreRatingOffline } from '@features/rating/services/rating-offline';
import { CoreRatingSyncProvider } from '@features/rating/services/rating-sync';
import { CoreUser } from '@features/user/services/user';
import { CanLeave } from '@guards/can-leave';
import { IonContent } from '@ionic/angular';
@ -34,7 +38,6 @@ import {
AddonModForumDiscussion,
AddonModForumPost,
AddonModForumProvider,
AddonModForumRatingInfo,
} from '../../services/forum';
import { AddonModForumHelper } from '../../services/helper';
import { AddonModForumOffline } from '../../services/offline';
@ -101,8 +104,8 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
protected syncObserver?: CoreEventObserver;
protected syncManualObserver?: CoreEventObserver;
ratingInfo?: AddonModForumRatingInfo;
hasOfflineRatings!: boolean;
ratingInfo?: CoreRatingInfo;
hasOfflineRatings = false;
protected ratingOfflineObserver?: CoreEventObserver;
protected ratingSyncObserver?: CoreEventObserver;
protected changeDiscObserver?: CoreEventObserver;
@ -203,7 +206,20 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
AddonModForum.invalidateDiscussionsList(this.forumId);
}
// @todo Listen for offline ratings saved and synced.
// Listen for offline ratings saved and synced.
this.ratingOfflineObserver = CoreEvents.on(CoreRatingProvider.RATING_SAVED_EVENT, (data) => {
if (data.component == 'mod_forum' && data.ratingArea == 'post' && data.contextLevel == ContextLevel.MODULE &&
data.instanceId == this.cmId && data.itemSetId == this.discussionId) {
this.hasOfflineRatings = true;
}
});
this.ratingSyncObserver = CoreEvents.on(CoreRatingSyncProvider.SYNCED_EVENT, async (data) => {
if (data.component == 'mod_forum' && data.ratingArea == 'post' && data.contextLevel == ContextLevel.MODULE &&
data.instanceId == this.cmId && data.itemSetId == this.discussionId) {
this.hasOfflineRatings = false;
}
});
this.changeDiscObserver = CoreEvents.on(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data => {
if ((this.forumId && this.forumId === data.forumId) || data.cmId === this.cmId) {
@ -345,7 +361,8 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
const response = await AddonModForum.getDiscussionPosts(this.discussionId, { cmId: this.cmId });
const replies = await AddonModForumOffline.getDiscussionReplies(this.discussionId);
const ratingInfo = response.ratinginfo;
this.ratingInfo = response.ratinginfo;
onlinePosts = response.posts;
this.courseId = response.courseid || this.courseId;
this.forumId = response.forumid || this.forumId;
@ -462,7 +479,6 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
}
this.posts = posts;
this.ratingInfo = ratingInfo;
this.postSubjects = this.getAllPosts().reduce(
(postSubjects, post) => {
postSubjects[post.id] = post.subject;
@ -487,7 +503,8 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes
this.canPin = false;
}
// @todo fetch hasOfflineRatings.
this.hasOfflineRatings =
await CoreRatingOffline.hasRatings('mod_forum', 'post', ContextLevel.MODULE, this.cmId, this.discussionId);
} catch (error) {
CoreDomUtils.showErrorModal(error);
} finally {

View File

@ -17,6 +17,7 @@ import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { CoreCourseCommonModWSOptions } from '@features/course/services/course';
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
import { CoreFileEntry } from '@features/fileuploader/services/fileuploader';
import { CoreRatingInfo } from '@features/rating/services/rating';
import { CoreUser } from '@features/user/services/user';
import { CoreApp } from '@services/app';
import { CoreFilepool } from '@services/filepool';
@ -553,7 +554,7 @@ export class AddonModForumProvider {
posts: AddonModForumPost[];
courseid?: number;
forumid?: number;
ratinginfo?: AddonModForumRatingInfo;
ratinginfo?: CoreRatingInfo;
}> {
// Convenience function to translate legacy data to new format.
const translateLegacyPostsFormat = (posts: AddonModForumLegacyPost[]): AddonModForumPost[] => posts.map((post) => {
@ -1526,40 +1527,6 @@ export type AddonModForumLegacyPost = {
}[];
};
/**
* Forum rating info.
*/
export type AddonModForumRatingInfo = {
contextid: number; // Context id.
component: string; // Context name.
ratingarea: string; // Rating area name.
canviewall?: boolean; // Whether the user can view all the individual ratings.
canviewany?: boolean; // Whether the user can view aggregate of ratings of others.
scales?: { // Different scales used information.
id: number; // Scale id.
courseid?: number; // Course id.
name?: string; // Scale name (when a real scale is used).
max: number; // Max value for the scale.
isnumeric: boolean; // Whether is a numeric scale.
items?: { // Scale items. Only returned for not numerical scales.
value: number; // Scale value/option id.
name: string; // Scale name.
}[];
}[];
ratings?: { // The ratings.
itemid: number; // Item id.
scaleid?: number; // Scale id.
userid?: number; // User who rated id.
aggregate?: number; // Aggregated ratings grade.
aggregatestr?: string; // Aggregated ratings as string.
aggregatelabel?: string; // The aggregation label.
count?: number; // Ratings count (used when aggregating).
rating?: number; // The rating the user gave.
canrate?: boolean; // Whether the user can rate the item.
canviewaggregate?: boolean; // Whether the user can view the aggregated grade.
}[];
};
/**
* Options to pass to get discussions.
*/
@ -1994,7 +1961,7 @@ export type AddonModForumGetDiscussionPostsWSResponse = {
posts: AddonModForumWSPost[];
forumid: number; // The forum id.
courseid: number; // The forum course id.
ratinginfo?: AddonModForumRatingInfo; // Rating information.
ratinginfo?: CoreRatingInfo; // Rating information.
warnings?: CoreWSExternalWarning[];
};
@ -2012,7 +1979,7 @@ export type AddonModForumGetForumDiscussionPostsWSParams = {
*/
export type AddonModForumGetForumDiscussionPostsWSResponse = {
posts: AddonModForumLegacyPost[];
ratinginfo?: AddonModForumRatingInfo; // Rating information.
ratinginfo?: CoreRatingInfo; // Rating information.
warnings?: CoreWSExternalWarning[];
};

View File

@ -12,11 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { ContextLevel } from '@/core/constants';
import { Injectable } from '@angular/core';
import { CoreSyncBaseProvider } from '@classes/base-sync';
import { CoreCourse } from '@features/course/services/course';
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
import { CoreFileUploader } from '@features/fileuploader/services/fileuploader';
import { CoreRatingSync } from '@features/rating/services/rating-sync';
import { CoreApp } from '@services/app';
import { CoreGroups } from '@services/groups';
import { CoreSites } from '@services/sites';
@ -327,14 +329,44 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider<AddonModForu
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if sync is successful, rejected otherwise.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async syncRatings(cmId?: number, discussionId?: number, force?: boolean, siteId?: string): Promise<{
updated: boolean;
warnings: string[];
}> {
// @todo
async syncRatings(cmId?: number, discussionId?: number, force?: boolean, siteId?: string): Promise<AddonModForumSyncResult> {
siteId = siteId || CoreSites.getCurrentSiteId();
return { updated: true, warnings: [] };
const results =
await CoreRatingSync.syncRatings('mod_forum', 'post', ContextLevel.MODULE, cmId, discussionId, force, siteId);
let updated = false;
const warnings: string[] = [];
const promises: Promise<void>[] = [];
results.forEach((result) => {
if (result.updated.length) {
updated = true;
// Invalidate discussions of updated ratings.
promises.push(AddonModForum.invalidateDiscussionPosts(result.itemSet!.itemSetId, undefined, siteId));
}
if (result.warnings.length) {
// Fetch forum to construct the warning message.
promises.push(AddonModForum.getForum(result.itemSet!.courseId!, result.itemSet!.instanceId, { siteId })
.then((forum) => {
result.warnings.forEach((warning) => {
warnings.push(Translate.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: forum.name,
error: warning,
}));
});
return;
}));
}
});
await CoreUtils.allPromises(promises);
return { updated, warnings };
}
/**

View File

@ -1,8 +1,5 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'addon.mod_imscp.toc' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -1,8 +1,5 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'core.comments.addcomment' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -58,6 +58,7 @@ import { CORE_MAINMENU_SERVICES } from '@features/mainmenu/mainmenu.module';
import { CORE_PUSHNOTIFICATIONS_SERVICES } from '@features/pushnotifications/pushnotifications.module';
import { CORE_QUESTION_SERVICES } from '@features/question/question.module';
// @todo import { CORE_SHAREDFILES_SERVICES } from '@features/sharedfiles/sharedfiles.module';
import { CORE_RATING_SERVICES } from '@features/rating/rating.module';
import { CORE_SEARCH_SERVICES } from '@features/search/search.module';
import { CORE_SETTINGS_SERVICES } from '@features/settings/settings.module';
import { CORE_SITEHOME_SERVICES } from '@features/sitehome/sitehome.module';
@ -266,6 +267,7 @@ export class CoreCompileProvider {
...CORE_LOGIN_SERVICES,
...CORE_QUESTION_SERVICES,
...CORE_PUSHNOTIFICATIONS_SERVICES,
...CORE_RATING_SERVICES,
...CORE_SEARCH_SERVICES,
...CORE_SETTINGS_SERVICES,
// @todo ...CORE_SHAREDFILES_SERVICES,

View File

@ -1,8 +1,5 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'core.courses.selfenrolment' | translate }}</ion-title>
<ion-buttons slot="end">

View File

@ -0,0 +1,144 @@
// (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 { ContextLevel } from '@/core/constants';
import { Component, Input, OnChanges, OnDestroy } from '@angular/core';
import {
CoreRating,
CoreRatingInfo,
CoreRatingInfoItem,
CoreRatingProvider,
} from '@features/rating/services/rating';
import { CoreSites } from '@services/sites';
import { ModalController } from '@singletons';
import { CoreEventObserver, CoreEvents, CoreEventSiteUpdatedData } from '@singletons/events';
import { CoreRatingRatingsComponent } from '../ratings/ratings';
/**
* Component that displays the aggregation of a rating item.
*/
@Component({
selector: 'core-rating-aggregate',
templateUrl: 'core-rating-aggregate.html',
})
export class CoreRatingAggregateComponent implements OnChanges, OnDestroy {
@Input() ratingInfo!: CoreRatingInfo;
@Input() contextLevel!: ContextLevel;
@Input() instanceId!: number;
@Input() itemId!: number;
@Input() aggregateMethod!: number;
@Input() scaleId!: number;
@Input() courseId?: number;
item?: CoreRatingInfoItem;
showCount = false;
disabled = false;
labelKey = '';
protected aggregateObserver: CoreEventObserver;
protected updateSiteObserver: CoreEventObserver;
constructor() {
this.disabled = CoreRating.isRatingDisabledInSite();
// Update visibility if current site info is updated.
this.updateSiteObserver = CoreEvents.on<CoreEventSiteUpdatedData>(CoreEvents.SITE_UPDATED, () => {
this.disabled = CoreRating.isRatingDisabledInSite();
}, CoreSites.getCurrentSiteId());
// Update aggrgate when the user adds or edits a rating.
this.aggregateObserver =
CoreEvents.on(CoreRatingProvider.AGGREGATE_CHANGED_EVENT, (data) => {
if (this.item &&
data.contextLevel == this.contextLevel &&
data.instanceId == this.instanceId &&
data.component == this.ratingInfo.component &&
data.ratingArea == this.ratingInfo.ratingarea &&
data.itemId == this.itemId) {
this.item.aggregatestr = data.aggregate;
this.item.count = data.count;
}
});
}
/**
* Detect changes on input properties.
*/
ngOnChanges(): void {
this.aggregateObserver && this.aggregateObserver.off();
this.item = (this.ratingInfo.ratings || []).find((rating) => rating.itemid == this.itemId);
if (!this.item) {
return;
}
switch (this.aggregateMethod) {
case CoreRatingProvider.AGGREGATE_AVERAGE:
this.labelKey = 'core.rating.aggregateavg';
break;
case CoreRatingProvider.AGGREGATE_COUNT:
this.labelKey = 'core.rating.aggregatecount';
break;
case CoreRatingProvider.AGGREGATE_MAXIMUM:
this.labelKey = 'core.rating.aggregatemax';
break;
case CoreRatingProvider.AGGREGATE_MINIMUM:
this.labelKey = 'core.rating.aggregatemin';
break;
case CoreRatingProvider.AGGREGATE_SUM:
this.labelKey = 'core.rating.aggregatesum';
break;
default:
this.labelKey = '';
return;
}
this.showCount = (this.aggregateMethod != CoreRatingProvider.AGGREGATE_COUNT);
}
/**
* Open the individual ratings page.
*/
async openRatings(): Promise<void> {
if (!this.ratingInfo.canviewall || !this.item!.count || this.disabled) {
return;
}
const modal = await ModalController.create({
component: CoreRatingRatingsComponent,
componentProps: {
contextLevel: this.contextLevel,
instanceId: this.instanceId,
ratingComponent: this.ratingInfo.component,
ratingArea: this.ratingInfo.ratingarea,
itemId: this.itemId,
scaleId: this.scaleId,
courseId: this.courseId,
},
});
await modal.present();
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.aggregateObserver.off();
this.updateSiteObserver.off();
}
}

View File

@ -0,0 +1,7 @@
<ion-item *ngIf="item && item!.canviewaggregate && labelKey && !disabled" class="ion-text-wrap"
[detail]="ratingInfo.canviewall && item!.count" (click)="openRatings()">
<ion-label>
{{ labelKey | translate }}{{ 'core.labelsep' | translate }} {{ item!.aggregatestr || '-' }}
<span *ngIf="showCount && item!.count && item!.count! > 0">({{ item!.count! }})</span>
</ion-label>
</ion-item>

View File

@ -0,0 +1,39 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CoreRatingAggregateComponent } from './aggregate/aggregate';
import { CoreRatingRateComponent } from './rate/rate';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreRatingRatingsComponent } from './ratings/ratings';
@NgModule({
declarations: [
CoreRatingAggregateComponent,
CoreRatingRateComponent,
CoreRatingRatingsComponent,
],
imports: [
CoreSharedModule,
],
exports: [
CoreRatingAggregateComponent,
CoreRatingRateComponent,
CoreRatingRatingsComponent,
],
entryComponents: [
CoreRatingRatingsComponent,
],
})
export class CoreRatingComponentsModule {}

View File

@ -0,0 +1,7 @@
<ion-item class="ion-text-wrap" *ngIf="item && (item!.canrate || item!.rating != null) && !disabled">
<ion-label>{{ 'core.rating.rating' | translate }}</ion-label>
<ion-select class="ion-text-start" [(ngModel)]="rating" (ngModelChange)="userRatingChanged()" interface="action-sheet"
[disabled]="!item!.canrate">
<ion-select-option *ngFor="let scaleItem of scale!.items" [value]="scaleItem.value">{{ scaleItem.name }}</ion-select-option>
</ion-select>
</ion-item>

View File

@ -0,0 +1,164 @@
// (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 { ContextLevel } from '@/core/constants';
import { Component, EventEmitter, Input, OnChanges, Output, OnDestroy } from '@angular/core';
import {
CoreRatingProvider,
CoreRatingInfo,
CoreRatingInfoItem,
CoreRatingScale,
CoreRating,
} from '@features/rating/services/rating';
import { CoreRatingOffline } from '@features/rating/services/rating-offline';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { Translate } from '@singletons';
import { CoreEventObserver, CoreEvents, CoreEventSiteUpdatedData } from '@singletons/events';
/**
* Component that displays the user rating select.
*/
@Component({
selector: 'core-rating-rate',
templateUrl: 'core-rating-rate.html',
})
export class CoreRatingRateComponent implements OnChanges, OnDestroy {
@Input() protected ratingInfo!: CoreRatingInfo;
@Input() protected contextLevel!: ContextLevel; // Context level: course, module, user, etc.
@Input() protected instanceId!: number; // Context instance id.
@Input() protected itemId!: number; // Item id. Example: forum post id.
@Input() protected itemSetId!: number; // Item set id. Example: forum discussion id.
@Input() protected courseId!: number;
@Input() protected aggregateMethod!: number;
@Input() protected scaleId!: number;
@Input() protected userId!: number;
@Output() protected onLoading: EventEmitter<boolean>; // Eevent that indicates whether the component is loading data.
@Output() protected onUpdate: EventEmitter<void>; // Event emitted when the rating is updated online.
item?: CoreRatingInfoItem;
scale?: CoreRatingScale;
rating?: number;
disabled = false;
protected updateSiteObserver: CoreEventObserver;
constructor() {
this.onLoading = new EventEmitter<boolean>();
this.onUpdate = new EventEmitter<void>();
this.disabled = CoreRating.isRatingDisabledInSite();
// Update visibility if current site info is updated.
this.updateSiteObserver = CoreEvents.on<CoreEventSiteUpdatedData>(CoreEvents.SITE_UPDATED, () => {
this.disabled = CoreRating.isRatingDisabledInSite();
}, CoreSites.getCurrentSiteId());
}
/**
* Detect changes on input properties.
*/
async ngOnChanges(): Promise<void> {
this.item = (this.ratingInfo.ratings || []).find((rating) => rating.itemid == this.itemId);
this.scale = (this.ratingInfo.scales || []).find((scale) => scale.id == this.scaleId);
if (!this.item || !this.scale || !CoreRating.isAddRatingWSAvailable()) {
this.item = undefined;
return;
}
// Set numeric scale items.
if (!this.scale.items) {
this.scale.items = [];
if (this.scale.isnumeric) {
for (let n = 0; n <= this.scale.max; n++) {
this.scale.items.push({ name: String(n), value: n });
}
}
}
// Add "No rating" item to the scale.
if (!this.scale.items[0] || this.scale.items[0].value != CoreRatingProvider.UNSET_RATING) {
this.scale.items.unshift({
name: Translate.instant('core.none'),
value: CoreRatingProvider.UNSET_RATING,
});
}
this.onLoading.emit(true);
try {
const rating = await CoreRatingOffline.getRating(
this.contextLevel,
this.instanceId,
this.ratingInfo.component,
this.ratingInfo.ratingarea,
this.itemId,
);
this.rating = rating.rating;
} catch {
if (this.item && this.item.rating != null) {
this.rating = this.item.rating;
} else {
this.rating = CoreRatingProvider.UNSET_RATING;
}
} finally {
this.onLoading.emit(false);
}
}
/**
* Send or save the user rating when changed.
*/
async userRatingChanged(): Promise<void> {
const modal = await CoreDomUtils.showModalLoading('core.sending', true);
try {
const response = await CoreRating.addRating(
this.ratingInfo.component,
this.ratingInfo.ratingarea,
this.contextLevel,
this.instanceId,
this.itemId,
this.itemSetId,
this.courseId,
this.scaleId,
this.rating!,
this.userId,
this.aggregateMethod,
);
if (typeof response == 'undefined') {
CoreDomUtils.showToast('core.datastoredoffline', true, 3000);
} else {
this.onUpdate.emit();
}
} catch (error) {
CoreDomUtils.showErrorModal(error);
} finally {
modal.dismiss();
}
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.updateSiteObserver.off();
}
}

View File

@ -0,0 +1,27 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ 'core.rating.ratings' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fas-times"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="loaded">
<ion-list *ngIf="ratings.length > 0">
<ion-item class="ion-text-wrap" *ngFor="let rating of ratings">
<core-user-avatar [user]="rating" [courseId]="courseId" slot="start"></core-user-avatar>
<ion-label>
<h2>{{ rating.userfullname }}</h2>
<p>{{ rating.rating }}</p>
</ion-label>
<ion-note slot="end" class="ion-padding-left" *ngIf="rating.timemodified">
{{ rating.timemodified | coreDateDayOrTime }}
</ion-note>
</ion-item>
</ion-list>
<core-empty-box *ngIf="ratings.length == 0" icon="fas-star-half-alt" [message]="'core.rating.noratings' | translate"></core-empty-box>
</core-loading>
</ion-content>

View File

@ -0,0 +1,70 @@
// (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 { ContextLevel } from '@/core/constants';
import { Component, Input, OnInit } from '@angular/core';
import { CoreRating, CoreRatingItemRating } from '@features/rating/services/rating';
import { CoreDomUtils } from '@services/utils/dom';
import { ModalController } from '@singletons';
/**
* Modal that displays individual ratings
*/
@Component({
templateUrl: 'ratings-modal.html',
})
export class CoreRatingRatingsComponent implements OnInit {
@Input() protected contextLevel!: ContextLevel;
@Input() protected instanceId!: number;
@Input() protected ratingComponent!: string;
@Input() protected ratingArea!: string;
@Input() protected aggregateMethod!: number;
@Input() protected itemId!: number;
@Input() protected scaleId!: number;
@Input() courseId!: number;
loaded = false;
ratings: CoreRatingItemRating[] = [];
/**
* Modal loaded.
*/
async ngOnInit(): Promise<void> {
try {
this.ratings = await CoreRating.getItemRatings(
this.contextLevel,
this.instanceId,
this.ratingComponent,
this.ratingArea,
this.itemId,
this.scaleId,
undefined,
this.courseId,
);
} catch (error) {
CoreDomUtils.showErrorModal(error);
} finally {
this.loaded = true;
}
}
/**
* Close modal.
*/
closeModal(): void {
ModalController.dismiss();
}
}

View File

@ -0,0 +1,10 @@
{
"aggregateavg": "Average of ratings",
"aggregatecount": "Count of ratings",
"aggregatemax": "Maximum rating",
"aggregatemin": "Minimum rating",
"aggregatesum": "Sum of ratings",
"noratings": "No ratings submitted",
"rating": "Rating",
"ratings": "Ratings"
}

View File

@ -0,0 +1,37 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule, Type } from '@angular/core';
import { CORE_SITE_SCHEMAS } from '@services/sites';
import { RATINGS_SITE_SCHEMA } from './services/database/rating';
import { CoreRatingProvider } from './services/rating';
import { CoreRatingOfflineProvider } from './services/rating-offline';
import { CoreRatingSyncProvider } from './services/rating-sync';
export const CORE_RATING_SERVICES: Type<unknown>[] = [
CoreRatingProvider,
CoreRatingSyncProvider,
CoreRatingOfflineProvider,
];
@NgModule({
providers: [
{
provide: CORE_SITE_SCHEMAS,
useValue: [RATINGS_SITE_SCHEMA],
multi: true,
},
],
})
export class CoreRatingModule {}

View File

@ -0,0 +1,101 @@
// (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 { ContextLevel } from '@/core/constants';
import { CoreSiteSchema } from '@services/sites';
/**
* Database variables for CoreRatingOffline service.
*/
export const RATINGS_TABLE = 'rating_ratings';
export const RATINGS_SITE_SCHEMA: CoreSiteSchema = {
name: 'CoreRatingOfflineProvider',
version: 1,
tables: [
{
name: RATINGS_TABLE,
columns: [
{
name: 'component',
type: 'TEXT',
},
{
name: 'ratingarea',
type: 'TEXT',
},
{
name: 'contextlevel',
type: 'INTEGER',
},
{
name: 'instanceid',
type: 'INTEGER',
},
{
name: 'itemid',
type: 'INTEGER',
},
{
name: 'itemsetid',
type: 'INTEGER',
},
{
name: 'courseid',
type: 'INTEGER',
},
{
name: 'scaleid',
type: 'INTEGER',
},
{
name: 'rating',
type: 'INTEGER',
},
{
name: 'rateduserid',
type: 'INTEGER',
},
{
name: 'aggregation',
type: 'INTEGER',
},
],
primaryKeys: ['component', 'ratingarea', 'contextlevel', 'instanceid', 'itemid'],
},
],
};
/**
* Primary data to identify a stored rating.
*/
export type CoreRatingDBPrimaryData = {
component: string;
ratingarea: string;
contextlevel: ContextLevel;
instanceid: number;
itemid: number;
};
/**
* Rating stored.
*/
export type CoreRatingDBRecord = CoreRatingDBPrimaryData & {
itemsetid: number;
courseid?: number;
scaleid: number;
rating: number;
rateduserid: number;
aggregation: number;
};

View File

@ -0,0 +1,271 @@
// (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 { ContextLevel } from '@/core/constants';
import { Injectable } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons';
import { CoreRatingDBPrimaryData, CoreRatingDBRecord, RATINGS_TABLE } from './database/rating';
/**
* Structure of item sets.
*/
export interface CoreRatingItemSet {
component: string;
ratingArea: string;
contextLevel: ContextLevel;
instanceId: number;
itemSetId: number;
courseId?: number;
}
/**
* Service to handle offline data for rating.
*/
@Injectable( { providedIn: 'root' })
export class CoreRatingOfflineProvider {
/**
* Get an offline rating.
*
* @param contextLevel Context level: course, module, user, etc.
* @param instanceId Context instance id.
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating area. Example: "post".
* @param itemId Item id. Example: forum post id.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the saved rating, rejected if not found.
*/
async getRating(
contextLevel: ContextLevel,
instanceId: number,
component: string,
ratingArea: string,
itemId: number,
siteId?: string,
): Promise<CoreRatingDBRecord> {
const site = await CoreSites.getSite(siteId);
const conditions: CoreRatingDBPrimaryData = {
contextlevel: contextLevel,
instanceid: instanceId,
component: component,
ratingarea: ratingArea,
itemid: itemId,
};
return site.getDb().getRecord(RATINGS_TABLE, conditions);
}
/**
* Add an offline rating.
*
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating area. Example: "post".
* @param contextLevel Context level: course, module, user, etc.
* @param instanceId Context instance id.
* @param itemId Item id. Example: forum post id.
* @param itemSetId Item set id. Example: forum discussion id.
* @param courseId Course id.
* @param scaleId Scale id.
* @param rating Rating value. Use CoreRatingProvider.UNSET_RATING to delete rating.
* @param ratedUserId Rated user id.
* @param aggregateMethod Aggregate method.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the rating is saved.
*/
async addRating(
component: string,
ratingArea: string,
contextLevel: ContextLevel,
instanceId: number,
itemId: number,
itemSetId: number,
courseId: number,
scaleId: number,
rating: number,
ratedUserId: number,
aggregateMethod: number,
siteId?: string,
): Promise<void> {
const site = await CoreSites.getSite(siteId);
const data: CoreRatingDBRecord = {
component: component,
ratingarea: ratingArea,
contextlevel: contextLevel,
instanceid: instanceId,
itemid: itemId,
itemsetid: itemSetId,
courseid: courseId,
scaleid: scaleId,
rating: rating,
rateduserid: ratedUserId,
aggregation: aggregateMethod,
};
await site.getDb().insertRecord(RATINGS_TABLE, data);
}
/**
* Delete offline rating.
*
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating area. Example: "post".
* @param contextLevel Context level: course, module, user, etc.
* @param itemId Item id. Example: forum post id.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the rating is saved.
*/
async deleteRating(
component: string,
ratingArea: string,
contextLevel: ContextLevel,
instanceId: number,
itemId: number,
siteId?: string,
): Promise<void> {
const site = await CoreSites.getSite(siteId);
const conditions: CoreRatingDBPrimaryData = {
component: component,
ratingarea: ratingArea,
contextlevel: contextLevel,
instanceid: instanceId,
itemid: itemId,
};
await site.getDb().deleteRecords(RATINGS_TABLE, conditions);
}
/**
* Get the list of item sets in a component or instance.
*
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating Area. Example: "post".
* @param contextLevel Context level: course, module, user, etc.
* @param instanceId Context instance id.
* @param itemSetId Item set id.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the list of item set ids.
*/
async getItemSets(
component: string,
ratingArea: string,
contextLevel?: ContextLevel,
instanceId?: number,
itemSetId?: number,
siteId?: string,
): Promise<CoreRatingItemSet[]> {
const site = await CoreSites.getSite(siteId);
const fields = 'DISTINCT contextlevel, instanceid, itemsetid, courseid';
const conditions: Partial<CoreRatingDBRecord> = {
component,
ratingarea: ratingArea,
};
if (contextLevel && instanceId) {
conditions.contextlevel = contextLevel;
conditions.instanceid = instanceId;
}
if (itemSetId) {
conditions.itemsetid = itemSetId;
}
const records = await site.getDb().getRecords<CoreRatingDBRecord>(RATINGS_TABLE, conditions, undefined, fields);
return records.map((record) => ({
component,
ratingArea,
contextLevel: record.contextlevel,
instanceId: record.instanceid,
itemSetId: record.itemsetid,
courseId: record.courseid,
}));
}
/**
* Get offline ratings of an item set.
*
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating Area. Example: "post".
* @param contextLevel Context level: course, module, user, etc.
* @param itemId Item id. Example: forum post id.
* @param itemSetId Item set id. Example: forum discussion id.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the list of ratings.
*/
async getRatings(
component: string,
ratingArea: string,
contextLevel: ContextLevel,
instanceId: number,
itemSetId: number,
siteId?: string,
): Promise<CoreRatingDBRecord[]> {
const site = await CoreSites.getSite(siteId);
const conditions: Partial<CoreRatingDBRecord> = {
component,
ratingarea: ratingArea,
contextlevel: contextLevel,
instanceid: instanceId,
itemsetid: itemSetId,
};
return site.getDb().getRecords(RATINGS_TABLE, conditions);
}
/**
* Return whether a component, instance or item set has offline ratings.
*
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating Area. Example: "post".
* @param contextLevel Context level: course, module, user, etc.
* @param instanceId Context instance id.
* @param itemSetId Item set id. Example: forum discussion id.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with a boolean.
*/
async hasRatings(
component: string,
ratingArea: string,
contextLevel?: ContextLevel,
instanceId?: number,
itemSetId?: number,
siteId?: string,
): Promise<boolean> {
const site = await CoreSites.getSite(siteId);
const conditions: Partial<CoreRatingDBRecord> = {
component,
ratingarea: ratingArea,
};
if (contextLevel && instanceId) {
conditions.contextlevel = contextLevel;
conditions.instanceid = instanceId;
}
if (itemSetId) {
conditions.itemsetid = itemSetId;
}
return CoreUtils.promiseWorks(site.getDb().recordExists(RATINGS_TABLE, conditions));
}
}
export const CoreRatingOffline = makeSingleton(CoreRatingOfflineProvider);

View File

@ -0,0 +1,315 @@
// (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 { ContextLevel } from '@/core/constants';
import { Injectable } from '@angular/core';
import { CoreSyncBaseProvider } from '@classes/base-sync';
import { CoreNetworkError } from '@classes/errors/network-error';
import { CoreApp } from '@services/app';
import { CoreSites } from '@services/sites';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons';
import { CoreEvents } from '@singletons/events';
import { CoreRating } from './rating';
import { CoreRatingItemSet, CoreRatingOffline } from './rating-offline';
/**
* Service to sync ratings.
*/
@Injectable( { providedIn: 'root' })
export class CoreRatingSyncProvider extends CoreSyncBaseProvider<CoreRatingSyncItem> {
static readonly SYNCED_EVENT = 'core_rating_synced';
constructor() {
super('CoreRatingSyncProvider');
}
/**
* Try to synchronize all the ratings of a certain component, instance or item set.
*
* This function should be called from the sync provider of activities with ratings.
*
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating Area. Example: "post".
* @param contextLevel Context level: course, module, user, etc.
* @param instanceId Context instance id.
* @param itemSetId Item set id.
* @param force Wether to force sync not depending on last execution.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if sync is successful, rejected if sync fails.
*/
async syncRatings(
component: string,
ratingArea: string,
contextLevel?: ContextLevel,
instanceId?: number,
itemSetId?: number,
force?: boolean,
siteId?: string,
): Promise<CoreRatingSyncItem[]> {
siteId = siteId || CoreSites.getCurrentSiteId();
const itemSets = await CoreRatingOffline.getItemSets(component, ratingArea, contextLevel, instanceId, itemSetId, siteId);
const results: CoreRatingSyncItem[] = [];
await Promise.all(itemSets.map(async (itemSet) => {
const result = force
? await this.syncItemSet(
component,
ratingArea,
itemSet.contextLevel,
itemSet.instanceId,
itemSet.itemSetId,
siteId,
)
: await this.syncItemSetIfNeeded(
component,
ratingArea,
itemSet.contextLevel,
itemSet.instanceId,
itemSet.itemSetId,
siteId,
);
if (result) {
if (result.updated) {
// Sync successful, send event.
CoreEvents.trigger(CoreRatingSyncProvider.SYNCED_EVENT, {
...itemSet,
warnings: result.warnings,
}, siteId);
}
results.push(
{
itemSet,
...result,
},
);
}
}));
return results;
}
/**
* Sync ratings of an item set only if a certain time has passed since the last time.
*
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating Area. Example: "post".
* @param contextLevel Context level: course, module, user, etc.
* @param instanceId Context instance id.
* @param itemSetId Item set id. Example: forum discussion id.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when ratings are synced or if it doesn't need to be synced.
*/
protected async syncItemSetIfNeeded(
component: string,
ratingArea: string,
contextLevel: ContextLevel,
instanceId: number,
itemSetId: number,
siteId?: string,
): Promise<CoreRatingSyncItem | undefined> {
siteId = siteId || CoreSites.getCurrentSiteId();
const syncId = this.getItemSetSyncId(component, ratingArea, contextLevel, instanceId, itemSetId);
const needed = await this.isSyncNeeded(syncId, siteId);
if (needed) {
return this.syncItemSet(component, ratingArea, contextLevel, instanceId, itemSetId, siteId);
}
}
/**
* Synchronize all offline ratings of an item set.
*
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating Area. Example: "post".
* @param contextLevel Context level: course, module, user, etc.
* @param instanceId Context instance id.
* @param itemSetId Item set id. Example: forum discussion id.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if sync is successful, rejected otherwise.
*/
protected syncItemSet(
component: string,
ratingArea: string,
contextLevel: ContextLevel,
instanceId: number,
itemSetId: number,
siteId?: string,
): Promise<CoreRatingSyncItem> {
siteId = siteId || CoreSites.getCurrentSiteId();
const syncId = this.getItemSetSyncId(component, ratingArea, contextLevel, instanceId, itemSetId);
if (this.isSyncing(syncId, siteId)) {
// There's already a sync ongoing for this item set, return the promise.
return this.getOngoingSync(syncId, siteId)!;
}
this.logger.debug(`Try to sync ratings of component '${component}' rating area '${ratingArea}'` +
` context level '${contextLevel}' instance ${instanceId} item set ${itemSetId}`);
// Get offline events.
const syncPromise = this.performSyncItemSet(component, ratingArea, contextLevel, instanceId, itemSetId, siteId);
return this.addOngoingSync(syncId, syncPromise, siteId);
}
/**
* Synchronize all offline ratings of an item set.
*
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating Area. Example: "post".
* @param contextLevel Context level: course, module, user, etc.
* @param instanceId Context instance id.
* @param itemSetId Item set id. Example: forum discussion id.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if sync is successful, rejected otherwise.
*/
protected async performSyncItemSet(
component: string,
ratingArea: string,
contextLevel: ContextLevel,
instanceId: number,
itemSetId: number,
siteId: string,
): Promise<CoreRatingSyncItem> {
const result: CoreRatingSyncItem = {
updated: [],
warnings: [],
};
const ratings = await CoreRatingOffline.getRatings(component, ratingArea, contextLevel, instanceId, itemSetId, siteId);
if (!ratings.length) {
// Nothing to sync.
return result;
}
if (!CoreApp.isOnline()) {
// Cannot sync in offline.
throw new CoreNetworkError();
}
const promises = ratings.map(async (rating) => {
try {
await CoreRating.addRatingOnline(
component,
ratingArea,
rating.contextlevel,
rating.instanceid,
rating.itemid,
rating.scaleid,
rating.rating,
rating.rateduserid,
rating.aggregation,
siteId,
);
} catch (error) {
if (!CoreUtils.isWebServiceError(error)) {
// Couldn't connect to server, reject.
throw error;
}
const warning = CoreTextUtils.getErrorMessageFromError(error);
if (warning) {
result.warnings.push(warning);
}
}
result.updated.push(rating.itemid);
try {
return CoreRatingOffline.deleteRating(
component,
ratingArea,
rating.contextlevel,
rating.instanceid,
rating.itemid,
siteId,
);
} finally {
await CoreRating.invalidateRatingItems(
rating.contextlevel,
rating.instanceid,
component,
ratingArea,
rating.itemid,
rating.scaleid,
undefined,
siteId,
);
}
});
await Promise.all(promises);
// All done, return the result.
return result;
}
/**
* Get the sync id of an item set.
*
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating Area. Example: "post".
* @param contextLevel Context level: course, module, user, etc.
* @param instanceId Context instance id.
* @param itemSetId Item set id. Example: forum discussion id.
* @return Sync id.
*/
protected getItemSetSyncId(
component: string,
ratingArea: string,
contextLevel: ContextLevel,
instanceId: number,
itemSetId: number,
): string {
return `itemSet#${component}#${ratingArea}#${contextLevel}#${instanceId}#${itemSetId}`;
}
}
export const CoreRatingSync = makeSingleton(CoreRatingSyncProvider);
declare module '@singletons/events' {
/**
* Augment CoreEventsData interface with events specific to this service.
*
* @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
*/
export interface CoreEventsData {
[CoreRatingSyncProvider.SYNCED_EVENT]: CoreRatingSyncEventData;
}
}
export type CoreRatingSyncItem = {
itemSet?: CoreRatingItemSet;
warnings: string[];
updated: number[];
};
/**
* Data passed to SYNCED_EVENT event.
*/
export type CoreRatingSyncEventData = CoreRatingItemSet & {
warnings: string[];
};

View File

@ -0,0 +1,588 @@
// (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 { ContextLevel } from '@/core/constants';
import { Injectable } from '@angular/core';
import { CoreSiteWSPreSets, CoreSite } from '@classes/site';
import { CoreUser } from '@features/user/services/user';
import { CoreApp } from '@services/app';
import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreWSExternalWarning } from '@services/ws';
import { makeSingleton } from '@singletons';
import { CoreEvents } from '@singletons/events';
import { CoreRatingOffline } from './rating-offline';
const ROOT_CACHE_KEY = 'CoreRating:';
/**
* Service to handle ratings.
*/
@Injectable( { providedIn: 'root' })
export class CoreRatingProvider {
static readonly AGGREGATE_NONE = 0; // No ratings.
static readonly AGGREGATE_AVERAGE = 1;
static readonly AGGREGATE_COUNT = 2;
static readonly AGGREGATE_MAXIMUM = 3;
static readonly AGGREGATE_MINIMUM = 4;
static readonly AGGREGATE_SUM = 5;
static readonly UNSET_RATING = -999;
static readonly AGGREGATE_CHANGED_EVENT = 'core_rating_aggregate_changed';
static readonly RATING_SAVED_EVENT = 'core_rating_rating_saved';
/**
* Returns whether the web serivce to add ratings is available.
*
* @return If WS is abalaible.
* @since 3.2
*/
isAddRatingWSAvailable(): boolean {
return CoreSites.wsAvailableInCurrentSite('core_rating_add_rating');
}
/**
* Add a rating to an item.
*
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating area. Example: "post".
* @param contextLevel Context level: course, module, user, etc.
* @param instanceId Context instance id.
* @param itemId Item id. Example: forum post id.
* @param itemSetId Item set id. Example: forum discussion id.
* @param courseId Course id.
* @param scaleId Scale id.
* @param rating Rating value. Use CoreRatingProvider.UNSET_RATING to delete rating.
* @param ratedUserId Rated user id.
* @param aggregateMethod Aggregate method.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the aggregated rating or void if stored offline.
* @since 3.2
*/
async addRating(
component: string,
ratingArea: string,
contextLevel: ContextLevel,
instanceId: number,
itemId: number,
itemSetId: number,
courseId: number,
scaleId: number,
rating: number,
ratedUserId: number,
aggregateMethod: number,
siteId?: string,
): Promise<CoreRatingAddRatingWSResponse | void> {
siteId = siteId || CoreSites.getCurrentSiteId();
// Convenience function to store a rating to be synchronized later.
const storeOffline = async (): Promise<void> => {
await CoreRatingOffline.addRating(
component,
ratingArea,
contextLevel,
instanceId,
itemId,
itemSetId,
courseId,
scaleId,
rating,
ratedUserId,
aggregateMethod,
siteId,
);
CoreEvents.trigger(CoreRatingProvider.RATING_SAVED_EVENT, {
component,
ratingArea,
contextLevel,
instanceId,
itemSetId,
itemId,
}, siteId);
};
if (!CoreApp.isOnline()) {
// App is offline, store the action.
return storeOffline();
}
try {
await CoreRatingOffline.deleteRating(component, ratingArea, contextLevel, instanceId, itemId, siteId);
this.addRatingOnline(
component,
ratingArea,
contextLevel,
instanceId,
itemId,
scaleId,
rating,
ratedUserId,
aggregateMethod,
siteId,
);
} catch (error) {
if (CoreUtils.isWebServiceError(error)) {
// The WebService has thrown an error or offline not supported, reject.
return Promise.reject(error);
}
// Couldn't connect to server, store offline.
return storeOffline();
}
}
/**
* Add a rating to an item. It will fail if offline or cannot connect.
*
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating area. Example: "post".
* @param contextLevel Context level: course, module, user, etc.
* @param instanceId Context instance id.
* @param itemId Item id. Example: forum post id.
* @param scaleId Scale id.
* @param rating Rating value. Use CoreRatingProvider.UNSET_RATING to delete rating.
* @param ratedUserId Rated user id.
* @param aggregateMethod Aggregate method.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the aggregated rating.
* @since 3.2
*/
async addRatingOnline(
component: string,
ratingArea: string,
contextLevel: ContextLevel,
instanceId: number,
itemId: number,
scaleId: number,
rating: number,
ratedUserId: number,
aggregateMethod: number,
siteId?: string,
): Promise<CoreRatingAddRatingWSResponse> {
const site = await CoreSites.getSite(siteId);
const params: CoreRatingAddRatingWSParams = {
contextlevel: contextLevel,
instanceid: instanceId,
component,
ratingarea: ratingArea,
itemid: itemId,
scaleid: scaleId,
rating,
rateduserid: ratedUserId,
aggregation: aggregateMethod,
};
const response = await site.write<CoreRatingAddRatingWSResponse>('core_rating_add_rating', params);
await this.invalidateRatingItems(contextLevel, instanceId, component, ratingArea, itemId, scaleId);
CoreEvents.trigger(CoreRatingProvider.AGGREGATE_CHANGED_EVENT, {
contextLevel,
instanceId,
component,
ratingArea,
itemId,
aggregate: response.aggregate,
count: response.count,
});
return response;
}
/**
* Get item ratings.
*
* @param contextLevel Context level: course, module, user, etc.
* @param instanceId Context instance id.
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating area. Example: "post".
* @param itemId Item id. Example: forum post id.
* @param scaleId Scale id.
* @param sort Sort field.
* @param courseId Course id. Used for fetching user profiles.
* @param siteId Site ID. If not defined, current site.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @return Promise resolved with the list of ratings.
*/
async getItemRatings(
contextLevel: ContextLevel,
instanceId: number,
component: string,
ratingArea: string,
itemId: number,
scaleId: number,
sort: string = 'timemodified',
courseId?: number,
siteId?: string,
ignoreCache: boolean = false,
): Promise<CoreRatingItemRating[]> {
const site = await CoreSites.getSite(siteId);
const params: CoreRatingGetItemRatingsWSParams = {
contextlevel: contextLevel,
instanceid: instanceId,
component,
ratingarea: ratingArea,
itemid: itemId,
scaleid: scaleId,
sort,
};
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getItemRatingsCacheKey(contextLevel, instanceId, component, ratingArea, itemId, scaleId, sort),
updateFrequency: CoreSite.FREQUENCY_RARELY,
};
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
const response = await site.read<CoreRatingGetItemRatingsWSResponse>('core_rating_get_item_ratings', params, preSets);
if (!site.isVersionGreaterEqualThan([' 3.6.5', '3.7.1', '3.8'])) {
// MDL-65042 We need to fetch profiles because the returned profile pictures are incorrect.
const promises = response.ratings.map((rating: CoreRatingItemRating) =>
CoreUser.getProfile(rating.userid, courseId, true, site.id).then((user) => {
rating.userpictureurl = user.profileimageurl || '';
return;
}).catch(() => {
// Ignore error.
rating.userpictureurl = '';
}));
await Promise.all(promises);
}
return response.ratings;
}
/**
* Invalidate item ratings.
*
* @param contextLevel Context level: course, module, user, etc.
* @param instanceId Context instance id.
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating area. Example: "post".
* @param itemId Item id. Example: forum post id.
* @param scaleId Scale id.
* @param sort Sort field.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the data is invalidated.
*/
async invalidateRatingItems(
contextLevel: ContextLevel,
instanceId: number,
component: string,
ratingArea: string,
itemId: number,
scaleId: number,
sort: string = 'timemodified',
siteId?: string,
): Promise<void> {
const site = await CoreSites.getSite(siteId);
const key = this.getItemRatingsCacheKey(contextLevel, instanceId, component, ratingArea, itemId, scaleId, sort);
await site.invalidateWsCacheForKey(key);
}
/**
* Check if rating is disabled in a certain site.
*
* @param site Site. If not defined, use current site.
* @return Whether it's disabled.
*/
isRatingDisabledInSite(site?: CoreSite): boolean {
site = site || CoreSites.getCurrentSite();
return !!site?.isFeatureDisabled('NoDelegate_CoreRating');
}
/**
* Check if rating is disabled in a certain site.
*
* @param siteId Site Id. If not defined, use current site.
* @return Promise resolved with true if disabled, rejected or resolved with false otherwise.
*/
async isRatingDisabled(siteId?: string): Promise<boolean> {
const site = await CoreSites.getSite(siteId);
return this.isRatingDisabledInSite(site);
}
/**
* Convenience function to merge two or more rating infos of the same instance.
*
* @param ratingInfos Array of rating infos.
* @return Merged rating info or undefined.
*/
mergeRatingInfos(ratingInfos: CoreRatingInfo[]): CoreRatingInfo | undefined {
let result: CoreRatingInfo | undefined;
const scales: Record<number, CoreRatingScale> = {};
const ratings: Record<number, CoreRatingInfoItem> = {};
ratingInfos.forEach((ratingInfo) => {
if (!ratingInfo) {
// Skip null rating infos.
return;
}
if (!result) {
result = Object.assign({}, ratingInfo);
}
(ratingInfo.scales || []).forEach((scale) => {
scales[scale.id] = scale;
});
(ratingInfo.ratings || []).forEach((rating) => {
ratings[rating.itemid] = rating;
});
});
if (result) {
result.scales = CoreUtils.objectToArray(scales);
result.ratings = CoreUtils.objectToArray(ratings);
}
return result;
}
/**
* Prefetch individual ratings.
*
* This function should be called from the prefetch handler of activities with ratings.
*
* @param contextLevel Context level: course, module, user, etc.
* @param instanceId Instance id.
* @param siteId Site id. If not defined, current site.
* @param courseId Course id. Used for prefetching user profiles.
* @param ratingInfo Rating info returned by web services.
* @return Promise resolved when done.
*/
async prefetchRatings(
contextLevel: ContextLevel,
instanceId: number,
scaleId: number,
courseId?: number,
ratingInfo?: CoreRatingInfo,
siteId?: string,
): Promise<void> {
if (!ratingInfo || !ratingInfo.ratings) {
return;
}
const site = await CoreSites.getSite(siteId);
const promises = ratingInfo.ratings.map((item) => this.getItemRatings(
contextLevel,
instanceId,
ratingInfo.component,
ratingInfo.ratingarea,
item.itemid,
scaleId,
undefined,
courseId,
site.id,
true,
));
if (!site.isVersionGreaterEqualThan([' 3.6.5', '3.7.1', '3.8'])) {
promises.map((promise) => promise.then(async (ratings) => {
const userIds = ratings.map((rating: CoreRatingItemRating) => rating.userid);
await CoreUser.prefetchProfiles(userIds, courseId, site.id);
return;
}));
}
await Promise.all(promises);
}
/**
* Get cache key for rating items WS calls.
*
* @param contextLevel Context level: course, module, user, etc.
* @param component Component. Example: "mod_forum".
* @param ratingArea Rating area. Example: "post".
* @param itemId Item id. Example: forum post id.
* @param scaleId Scale id.
* @param sort Sort field.
* @return Cache key.
*/
protected getItemRatingsCacheKey(
contextLevel: ContextLevel,
instanceId: number,
component: string,
ratingArea: string,
itemId: number,
scaleId: number,
sort: string,
): string {
return `${ROOT_CACHE_KEY}${contextLevel}:${instanceId}:${component}:${ratingArea}:${itemId}:${scaleId}:${sort}`;
}
}
export const CoreRating = makeSingleton(CoreRatingProvider);
declare module '@singletons/events' {
/**
* Augment CoreEventsData interface with events specific to this service.
*
* @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
*/
export interface CoreEventsData {
[CoreRatingProvider.AGGREGATE_CHANGED_EVENT]: CoreRatingAggregateChangedEventData;
[CoreRatingProvider.RATING_SAVED_EVENT]: CoreRatingSavedEventData;
}
}
/**
* Structure of the rating info returned by web services.
*/
export type CoreRatingInfo = {
contextid: number; // Context id.
component: string; // Context name.
ratingarea: string; // Rating area name.
canviewall?: boolean; // Whether the user can view all the individual ratings.
canviewany?: boolean; // Whether the user can view aggregate of ratings of others.
scales?: CoreRatingScale[]; // Different scales used information.
ratings?: CoreRatingInfoItem[]; // The ratings.
};
/**
* Structure of scales in the rating info.
*/
export type CoreRatingScale = {
id: number; // Scale id.
courseid?: number; // Course id.
name?: string; // Scale name (when a real scale is used).
max: number; // Max value for the scale.
isnumeric: boolean; // Whether is a numeric scale.
items?: { // Scale items. Only returned for not numerical scales.
value: number; // Scale value/option id.
name: string; // Scale name.
}[];
};
/**
* Structure of items in the rating info.
*/
export type CoreRatingInfoItem = {
itemid: number; // Item id.
scaleid?: number; // Scale id.
scale?: CoreRatingScale; // Added for rendering purposes.
userid?: number; // User who rated id.
aggregate?: number; // Aggregated ratings grade.
aggregatestr?: string; // Aggregated ratings as string.
aggregatelabel?: string; // The aggregation label.
count?: number; // Ratings count (used when aggregating).
rating?: number; // The rating the user gave.
canrate?: boolean; // Whether the user can rate the item.
canviewaggregate?: boolean; // Whether the user can view the aggregated grade.
};
/**
* Structure of a rating returned by the item ratings web service.
*/
export type CoreRatingItemRating = {
id: number; // Rating id.
userid: number; // User id.
userpictureurl: string; // URL user picture.
userfullname: string; // User fullname.
rating: string; // Rating on scale.
timemodified: number; // Time modified (timestamp).
};
/**
* Params of core_rating_get_item_ratings WS.
*/
type CoreRatingGetItemRatingsWSParams = {
contextlevel: ContextLevel; // Context level: course, module, user, etc...
instanceid: number; // The instance id of item associated with the context level.
component: string; // Component.
ratingarea: string; // Rating area.
itemid: number; // Associated id.
scaleid: number; // Scale id.
sort: string; // Sort order (firstname, rating or timemodified).
};
/**
* Data returned by core_rating_get_item_ratings WS.
*/
export type CoreRatingGetItemRatingsWSResponse = {
ratings: CoreRatingItemRating[];
warnings?: CoreWSExternalWarning[];
};
/**
* Params of core_rating_add_rating WS.
*/
type CoreRatingAddRatingWSParams = {
contextlevel: ContextLevel; // Context level: course, module, user, etc...
instanceid: number; // The instance id of item associated with the context level.
component: string; // Component.
ratingarea: string; // Rating area.
itemid: number; // Associated id.
scaleid: number; // Scale id.
rating: number; // User rating.
rateduserid: number; // Rated user id.
aggregation?: number; // Agreggation method.
};
/**
* Data returned by core_rating_add_rating WS.
*/
export type CoreRatingAddRatingWSResponse = {
success: boolean; // Whether the rate was successfully created.
aggregate?: string; // New aggregate.
count?: number; // Ratings count.
itemid?: number; // Rating item id.
warnings?: CoreWSExternalWarning[];
};
/**
* Data sent by AGGREGATE_CHANGED_EVENT event.
*/
export type CoreRatingAggregateChangedEventData = {
contextLevel: ContextLevel;
instanceId: number;
component: string;
ratingArea: string;
itemId: number;
aggregate?: string;
count?: number;
};
/**
* Data sent by RATING_SAVED_EVENT event.
*/
export type CoreRatingSavedEventData = {
component: string;
ratingArea: string;
contextLevel: ContextLevel;
instanceId: number;
itemSetId: number;
itemId: number;
};

View File

@ -1,8 +1,5 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ title }}</ion-title>
<ion-buttons slot="end">