Merge pull request #4181 from albertgasset/MOBILE-4639

MOBILE-4639 badges: Support links to badges/badgeclass.php?id=X
main
Dani Palou 2024-09-19 10:22:01 +02:00 committed by GitHub
commit 1ef4aa6b2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 404 additions and 32 deletions

View File

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

View File

@ -17,6 +17,7 @@ import { Routes } from '@angular/router';
import { AddonBadgesMyBadgesLinkHandler } from './services/handlers/mybadges-link';
import { AddonBadgesBadgeLinkHandler } from './services/handlers/badge-link';
import { AddonBadgesBadgeClassLinkHandler } from './services/handlers/badgeclass-link';
import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreUserDelegate } from '@features/user/services/user-delegate';
import { AddonBadgesUserHandler } from './services/handlers/user';
@ -48,6 +49,10 @@ const mainMenuRoutes: Routes = [
path: 'badges',
loadChildren: () => import('./badges-lazy.module').then(m => m.AddonBadgesLazyModule),
},
{
path: 'badgeclass',
loadChildren: () => import('./badgeclass-lazy.module').then(m => m.AddonBadgeClassLazyModule),
},
];
@NgModule({
@ -61,6 +66,7 @@ const mainMenuRoutes: Routes = [
useValue: () => {
CoreContentLinksDelegate.registerHandler(AddonBadgesMyBadgesLinkHandler.instance);
CoreContentLinksDelegate.registerHandler(AddonBadgesBadgeLinkHandler.instance);
CoreContentLinksDelegate.registerHandler(AddonBadgesBadgeClassLinkHandler.instance);
CoreUserDelegate.registerHandler(AddonBadgesUserHandler.instance);
CorePushNotificationsDelegate.registerClickHandler(AddonBadgesPushClickHandler.instance);
CoreTagAreaDelegate.registerHandler(AddonBadgesTagAreaHandler.instance);

View File

@ -0,0 +1,68 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1 *ngIf="badge">{{ badge.name }}</h1>
<h1 *ngIf="!badge">{{ 'addon.badges.badgedetails' | translate }}</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="limited-width">
<ion-refresher slot="fixed" [disabled]="!badgeLoaded" (ionRefresh)="refreshBadgeClass($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="badgeLoaded">
<ng-container *ngIf="badge">
<ion-item-group>
<ion-item class="ion-text-wrap ion-text-center">
<ion-label>
<img *ngIf="badge.image" class="large-avatar" [url]="badge.image" core-external-content [alt]="badge.name" />
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.name">
<ion-label>
<p class="item-heading">{{ 'core.name' | translate}}</p>
<p>{{ badge.name }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.issuer">
<ion-label>
<p class="item-heading">{{ 'addon.badges.issuername' | translate}}</p>
<p>{{ badge.issuer }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.coursefullname">
<ion-label>
<p class="item-heading">{{ 'core.course' | translate}}</p>
<p>
<core-format-text [text]="badge.coursefullname" contextLevel="course" [contextInstanceId]="badge.courseid" />
</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.description">
<ion-label>
<p class="item-heading">{{ 'core.description' | translate}}</p>
<p>{{ badge.description }}</p>
</ion-label>
</ion-item>
</ion-item-group>
<!-- Competencies alignment -->
<ion-item-group *ngIf="badge.alignment?.length">
<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
[autoLogin]="false">
<ion-label>
<p class="item-heading">{{ alignment.targetName }}</p>
</ion-label>
</ion-item>
</ion-item-group>
</ng-container>
</core-loading>
</ion-content>

View File

@ -0,0 +1,91 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit } from '@angular/core';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreNavigator } from '@services/navigator';
import { ActivatedRoute } from '@angular/router';
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
import { CoreTime } from '@singletons/time';
import { AddonBadges, AddonBadgesBadgeClass } from '../../services/badges';
/**
* Page that displays a badge class.
*/
@Component({
selector: 'page-addon-badges-badge-class',
templateUrl: 'badge-class.html',
})
export class AddonBadgesBadgeClassPage implements OnInit {
protected badgeId = 0;
protected logView: (badge: AddonBadgesBadgeClass) => void;
badge?: AddonBadgesBadgeClass;
badgeLoaded = false;
currentTime = 0;
constructor(protected route: ActivatedRoute) {
this.badgeId = CoreNavigator.getRequiredRouteNumberParam('badgeId');
this.logView = CoreTime.once((badge) => {
CoreAnalytics.logEvent({
type: CoreAnalyticsEventType.VIEW_ITEM,
ws: 'core_badges_get_badge',
name: badge.name,
data: { id: this.badgeId, category: 'badges' },
url: `/badges/badgeclass.php?id=${this.badgeId}`,
});
});
}
/**
* View loaded.
*/
ngOnInit(): void {
this.fetchBadgeClass().finally(() => {
this.badgeLoaded = true;
});
}
/**
* Fetch the badge class required for the view.
*
* @returns Promise resolved when done.
*/
async fetchBadgeClass(): Promise<void> {
try {
this.badge = await AddonBadges.getBadgeClass(this.badgeId);
this.logView(this.badge);
} catch (message) {
CoreDomUtils.showErrorModalDefault(message, 'Error getting badge data.');
}
}
/**
* Refresh the badge class.
*
* @param refresher Refresher.
*/
async refreshBadgeClass(refresher?: HTMLIonRefresherElement): Promise<void> {
await CoreUtils.ignoreErrors(AddonBadges.invalidateBadgeClass(this.badgeId));
await this.fetchBadgeClass();
refresher?.complete();
}
}

View File

@ -111,11 +111,11 @@
<p>{{ badge.imagecaption }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="course">
<ion-item class="ion-text-wrap" *ngIf="badge.coursefullname">
<ion-label>
<p class="item-heading">{{ 'core.course' | translate}}</p>
<p>
<core-format-text [text]="course.fullname" contextLevel="course" [contextInstanceId]="courseId" />
<core-format-text [text]="badge.coursefullname" contextLevel="course" [contextInstanceId]="badge.courseid" />
</p>
</ion-label>
</ion-item>
@ -217,21 +217,16 @@
</ion-item-group>
<!-- Competencies alignment -->
<ion-item-group *ngIf="badge.alignment">
<ion-item-group *ngIf="badge.alignment?.length">
<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
<ion-item class="ion-text-wrap" *ngFor="let alignment of badge.alignment" [href]="alignment.targetUrl" core-link
[autoLogin]="false">
<ion-label>
<p class="item-heading">{{ alignment.targetname }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.alignment.length === 0">
<ion-label>
<p class="item-heading">{{ 'addon.badges.noalignment' | translate}}</p>
<p class="item-heading">{{ alignment.targetName }}</p>
</ion-label>
</ion-item>
</ion-item-group>

View File

@ -19,7 +19,7 @@ import { CoreSites } from '@services/sites';
import { CoreUser } from '@features/user/services/user';
import { AddonBadges, AddonBadgesUserBadge } from '../../services/badges';
import { CoreUtils } from '@services/utils/utils';
import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses';
import { CoreCourses } from '@features/courses/services/courses';
import { CoreNavigator } from '@services/navigator';
import { ActivatedRoute } from '@angular/router';
import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager';
@ -30,7 +30,7 @@ import { CoreTime } from '@singletons/time';
import { CoreSharedModule } from '@/core/shared.module';
/**
* Page that displays the list of calendar events.
* Page that displays an issued badge.
*/
@Component({
selector: 'page-addon-badges-issued-badge',
@ -47,7 +47,6 @@ export class AddonBadgesIssuedBadgePage implements OnInit, OnDestroy {
protected logView: (badge: AddonBadgesUserBadge) => void;
courseId = 0;
course?: CoreEnrolledCourseData;
badge?: AddonBadgesUserBadge;
badges?: CoreSwipeNavigationItemsManager;
badgeLoaded = false;
@ -128,16 +127,18 @@ export class AddonBadgesIssuedBadgePage implements OnInit, OnDestroy {
}
}
this.badge = badge;
if (badge.courseid) {
// Try to get course full name if not returned by the WS.
if (badge.courseid && !badge.coursefullname) {
try {
this.course = await CoreCourses.getUserCourse(badge.courseid, true);
const course = await CoreCourses.getUserCourse(badge.courseid, true);
badge.coursefullname = course.fullname;
} catch {
// Maybe an old deleted course.
this.course = undefined;
// User is not enrolled in the course.
}
}
this.badge = badge;
this.logView(badge);
} catch (message) {
CoreDomUtils.showErrorModalDefault(message, 'Error getting badge data.');

View File

@ -78,15 +78,18 @@ export class AddonBadgesProvider {
throw new CoreError('Invalid badges response');
}
// In 3.7, competencies was renamed to alignment. Rename the property in 3.6 too.
response.badges.forEach((badge) => {
// In 3.7, competencies was renamed to alignment.
if (!badge.alignment && badge.competencies) {
badge.alignment = badge.competencies.map((competency) => ({
targetName: competency.targetname,
targetUrl: competency.targeturl,
}));
}
badge.alignment = badge.alignment || badge.competencies;
// Check that the alignment is valid, they were broken in 3.7.
if (badge.alignment && badge.alignment[0] && badge.alignment[0].targetname === undefined) {
// If any badge lacks targetname it means they are affected by the Moodle bug, don't display them.
delete badge.alignment;
}
// Exclude alignments without targetName, we can't display them.
badge.alignment = badge.alignment?.filter((alignment) => alignment.targetName);
});
return response.badges;
@ -138,11 +141,15 @@ export class AddonBadgesProvider {
data,
preSets,
);
if (!response || !response.badge?.[0]) {
const badge = response?.badge?.[0];
if (!badge) {
throw new CoreError('Invalid badge response');
}
return response.badge[0];
// Exclude alignments without targetName, we can't display them.
badge.alignment = badge.alignment?.filter((alignment) => alignment.targetName);
return badge;
}
/**
@ -158,6 +165,76 @@ export class AddonBadgesProvider {
await site.invalidateWsCacheForKey(this.getUserBadgeByHashCacheKey(hash));
}
/**
* Get the cache key for the get badge class WS call.
*
* @param id Badge ID.
* @returns Cache key.
*/
protected getBadgeClassCacheKey(id: number): string {
return ROOT_CACHE_KEY + 'badgeclass:' + id;
}
/**
* Get badge class.
*
* @param id Badge ID.
* @param siteId Site ID. If not defined, current site.
* @returns Promise to be resolved when the badge is retrieved.
* @since 4.5
*/
async getBadgeClass(id: number, siteId?: string): Promise<AddonBadgesBadgeClass> {
const site = await CoreSites.getSite(siteId);
const data: AddonBadgesGetBadgeClassWSParams = {
id,
};
const preSets = {
cacheKey: this.getBadgeClassCacheKey(id),
updateFrequency: CoreSite.FREQUENCY_RARELY,
};
const response = await site.read<AddonBadgesGetBadgeClassWSResponse>(
'core_badges_get_badge',
data,
preSets,
);
const badge = response?.badge;
if (!badge) {
throw new CoreError('Invalid badge response');
}
// Exclude alignments without targetName, we can't display them.
badge.alignment = badge.alignment?.filter((alignment) => alignment.targetName);
return badge;
}
/**
* Invalidate get badge class WS call.
*
* @param id Badge ID.
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when data is invalidated.ç
* @since 4.5
*/
async invalidateBadgeClass(id: number, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
await site.invalidateWsCacheForKey(this.getBadgeClassCacheKey(id));
}
/**
* Returns whether get badge class WS is available.
*
* @param siteId Site ID. If not defined, current site.
* @returns If WS is available.
*/
async isGetBadgeClassAvailable(siteId?: string): Promise<boolean> {
const site = await CoreSites.getSite(siteId);
return site.wsAvailable('core_badges_get_badge');
}
}
export const AddonBadges = makeSingleton(AddonBadgesProvider);
@ -208,6 +285,7 @@ export type AddonBadgesUserBadge = {
expireperiod?: number; // Expire period.
type?: number; // Type.
courseid?: number; // Course id.
coursefullname?: string; // Full name of the course.
message?: string; // Message.
messagesubject?: string; // Message subject.
attachment?: number; // Attachment.
@ -242,11 +320,11 @@ export type AddonBadgesUserBadge = {
alignment?: { // @since 3.7. Calculated by the app for 3.6 sites. Badge alignments.
id?: number; // Alignment id.
badgeid?: number; // Badge id.
targetname?: string; // Target name.
targeturl?: string; // Target URL.
targetdescription?: string; // Target description.
targetframework?: string; // Target framework.
targetcode?: string; // Target code.
targetName?: string; // Target name.
targetUrl?: string; // Target URL.
targetDescription?: string; // Target description.
targetFramework?: string; // Target framework.
targetCode?: string; // Target code.
}[];
competencies?: { // @deprecatedonmoodle since 3.7. @since 3.6. In 3.7 it was renamed to alignment.
id?: number; // Alignment id.
@ -280,3 +358,44 @@ type AddonBadgesGetUserBadgeByHashWSResponse = {
badge: AddonBadgesUserBadge[];
warnings?: CoreWSExternalWarning[];
};
/**
* Params of core_badges_get_badge WS.
*/
type AddonBadgesGetBadgeClassWSParams = {
id: number; // Badge ID.
};
/**
* Data returned by core_badges_get_badge WS.
*/
type AddonBadgesGetBadgeClassWSResponse = {
badge: AddonBadgesBadgeClass;
warnings?: CoreWSExternalWarning[];
};
/**
* Badge data returned by core_badges_get_badge WS.
*/
export type AddonBadgesBadgeClass = {
type: string; // BadgeClass.
id: string; // Unique identifier for this badgeclass (URL).
issuer?: string; // Issuer for this badgeclass.
name: string; // Name of the badgeclass.
image: string; // URL to the image.
description: string; // Description of the badge class.
hostedUrl?: string; // Identifier of the open badge for this assertion.
courseid?: number; // Course ID.
coursefullname?: string; // Full name of the course.
alignment?: { // Badge alignments.
id?: number; // Alignment id.
badgeid?: number; // Badge id.
targetName?: string; // Target name.
targetUrl?: string; // Target URL.
targetDescription?: string; // Target description.
targetFramework?: string; // Target framework.
targetCode?: string; // Target code.
}[];
criteriaUrl?: string; // Criteria URL.
criteriaNarrative?: string; // Criteria narrative.
};

View File

@ -21,7 +21,7 @@ import { makeSingleton } from '@singletons';
import { AddonBadgesHelper } from '../badges-helper';
/**
* Handler to treat links to user participants page.
* Handler to treat links to issued badges.
*/
@Injectable({ providedIn: 'root' })
export class AddonBadgesBadgeLinkHandlerService extends CoreContentLinksHandlerBase {

View File

@ -0,0 +1,56 @@
// (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 { Injectable } from '@angular/core';
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
import { CoreNavigator } from '@services/navigator';
import { makeSingleton } from '@singletons';
import { AddonBadges } from '../badges';
/**
* Handler to treat links to badge classes.
*/
@Injectable({ providedIn: 'root' })
export class AddonBadgesBadgeClassLinkHandlerService extends CoreContentLinksHandlerBase {
name = 'AddonBadgesBadgeClassLinkHandler';
pattern = /\/badges\/badgeclass\.php.*([?&]id=)/;
/**
* @inheritdoc
*/
getActions(siteIds: string[], url: string, params: Record<string, string>): CoreContentLinksAction[] {
return [{
action: async (siteId: string): Promise<void> => {
await CoreNavigator.navigateToSitePath(`/badgeclass/${params.id}`, { siteId });
},
}];
}
/**
* @inheritdoc
*/
async isEnabled(siteId: string): Promise<boolean> {
const pluginEnabled = await AddonBadges.isPluginEnabled(siteId);
const wsAvailable = await AddonBadges.isGetBadgeClassAvailable(siteId);
return pluginEnabled && wsAvailable;
}
}
export const AddonBadgesBadgeClassLinkHandler = makeSingleton(AddonBadgesBadgeClassLinkHandlerService);