Merge pull request #3938 from crazyserver/MOBILE-4329

Mobile 4329
main
Dani Palou 2024-02-27 13:48:33 +01:00 committed by GitHub
commit 60e256eda6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1671 additions and 219 deletions

View File

@ -84,6 +84,7 @@ jobs:
"@core_comments"
"@core_course"
"@core_courses"
"@core_dataprivacy"
"@core_grades"
"@core_login"
"@core_mainmenu"

View File

@ -1677,6 +1677,40 @@
"core.courses.totalcoursesearchresults": "local_moodlemobileapp",
"core.currentdevice": "local_moodlemobileapp",
"core.custom": "form",
"core.dataprivacy.cancelrequest": "tool_dataprivacy",
"core.dataprivacy.cancelrequestconfirmation": "tool_dataprivacy",
"core.dataprivacy.contactdataprotectionofficer": "tool_dataprivacy",
"core.dataprivacy.createnewdatarequest": "tool_dataprivacy",
"core.dataprivacy.datarequests": "tool_dataprivacy",
"core.dataprivacy.daterequested": "tool_dataprivacy",
"core.dataprivacy.deletemyaccount": "tool_dataprivacy",
"core.dataprivacy.message": "tool_dataprivacy",
"core.dataprivacy.newrequest": "tool_dataprivacy",
"core.dataprivacy.nodatarequests": "tool_dataprivacy",
"core.dataprivacy.pluginname": "tool_dataprivacy",
"core.dataprivacy.replyto": "tool_dataprivacy",
"core.dataprivacy.requestactions": "tool_dataprivacy",
"core.dataprivacy.requestby": "tool_dataprivacy",
"core.dataprivacy.requestcomments": "tool_dataprivacy",
"core.dataprivacy.requeststatus": "tool_dataprivacy",
"core.dataprivacy.requestsubmitted": "tool_dataprivacy",
"core.dataprivacy.requesttype": "tool_dataprivacy",
"core.dataprivacy.requesttype_help": "tool_dataprivacy",
"core.dataprivacy.requesttypedelete": "tool_dataprivacy",
"core.dataprivacy.requesttypeexport": "tool_dataprivacy",
"core.dataprivacy.requesttypeothers": "tool_dataprivacy",
"core.dataprivacy.send": "tool_dataprivacy",
"core.dataprivacy.statusapproved": "tool_dataprivacy",
"core.dataprivacy.statusawaitingapproval": "tool_dataprivacy",
"core.dataprivacy.statuscancelled": "tool_dataprivacy",
"core.dataprivacy.statuscomplete": "tool_dataprivacy",
"core.dataprivacy.statusdeleted": "tool_dataprivacy",
"core.dataprivacy.statusexpired": "tool_dataprivacy",
"core.dataprivacy.statuspending": "tool_dataprivacy",
"core.dataprivacy.statuspreprocessing": "tool_dataprivacy",
"core.dataprivacy.statusprocessing": "tool_dataprivacy",
"core.dataprivacy.statusready": "tool_dataprivacy",
"core.dataprivacy.statusrejected": "tool_dataprivacy",
"core.datastoredoffline": "local_moodlemobileapp",
"core.date": "moodle",
"core.datecreated": "repository",

View File

@ -16,9 +16,9 @@ import { Injectable } from '@angular/core';
import { CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses';
import {
CoreUserDelegateContext,
CoreUserDelegateService,
CoreUserProfileHandler,
CoreUserProfileHandlerData,
CoreUserProfileHandlerType,
} from '@features/user/services/user-delegate';
import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
@ -33,7 +33,7 @@ export class AddonBadgesUserHandlerService implements CoreUserProfileHandler {
name = 'AddonBadges:fakename'; // This name doesn't match any disabled feature, they'll be checked in isEnabledForContext.
priority = 300;
type = CoreUserDelegateService.TYPE_NEW_PAGE;
type = CoreUserProfileHandlerType.LIST_ITEM;
/**
* @inheritdoc

View File

@ -16,7 +16,7 @@ import { Injectable } from '@angular/core';
import {
CoreUserProfileHandler,
CoreUserProfileHandlerData,
CoreUserDelegateService,
CoreUserProfileHandlerType,
CoreUserDelegateContext,
} from '@features/user/services/user-delegate';
import { CoreNavigator } from '@services/navigator';
@ -32,7 +32,7 @@ export class AddonBlogUserHandlerService implements CoreUserProfileHandler {
name = 'AddonBlog'; // This name doesn't match any disabled feature, they'll be checked in isEnabledForContext.
priority = 200;
type = CoreUserDelegateService.TYPE_NEW_PAGE;
type = CoreUserProfileHandlerType.LIST_ITEM;
/**
* @inheritdoc

View File

@ -18,7 +18,7 @@ import { COURSE_PAGE_NAME } from '@features/course/course.module';
import { CoreUserProfile } from '@features/user/services/user';
import {
CoreUserProfileHandler,
CoreUserDelegateService,
CoreUserProfileHandlerType,
CoreUserProfileHandlerData,
CoreUserDelegateContext,
} from '@features/user/services/user-delegate';
@ -36,7 +36,7 @@ export class AddonCompetencyUserHandlerService implements CoreUserProfileHandler
name = 'AddonCompetency'; // This name doesn't match any disabled feature, they'll be checked in isEnabledForContext.
priority = 100;
type = CoreUserDelegateService.TYPE_NEW_PAGE;
type = CoreUserProfileHandlerType.LIST_ITEM;
cacheEnabled = true;
/**

View File

@ -16,7 +16,7 @@ import { Injectable } from '@angular/core';
import { CoreUserProfile } from '@features/user/services/user';
import {
CoreUserProfileHandler,
CoreUserDelegateService,
CoreUserProfileHandlerType,
CoreUserProfileHandlerData,
CoreUserDelegateContext,
} from '@features/user/services/user-delegate';
@ -31,7 +31,7 @@ import { AddonCourseCompletion } from '../coursecompletion';
export class AddonCourseCompletionUserHandlerService implements CoreUserProfileHandler {
name = 'AddonCourseCompletion:viewCompletion';
type = CoreUserDelegateService.TYPE_NEW_PAGE;
type = CoreUserProfileHandlerType.LIST_ITEM;
priority = 350;
cacheEnabled = true;

View File

@ -15,7 +15,11 @@
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import { CoreUserProfile } from '@features/user/services/user';
import { CoreUserDelegateService, CoreUserProfileHandler, CoreUserProfileHandlerData } from '@features/user/services/user-delegate';
import {
CoreUserProfileHandlerType,
CoreUserProfileHandler,
CoreUserProfileHandlerData,
} from '@features/user/services/user-delegate';
import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { makeSingleton } from '@singletons';
@ -29,7 +33,7 @@ export class AddonMessagesSendMessageUserHandlerService implements CoreUserProfi
name = 'AddonMessages:sendMessage';
priority = 1000;
type = CoreUserDelegateService.TYPE_COMMUNICATION;
type = CoreUserProfileHandlerType.BUTTON;
/**
* @inheritdoc

View File

@ -16,7 +16,7 @@ import { Injectable } from '@angular/core';
import { CoreUserProfile } from '@features/user/services/user';
import {
CoreUserProfileHandler,
CoreUserDelegateService,
CoreUserProfileHandlerType,
CoreUserProfileHandlerData,
CoreUserDelegateContext,
} from '@features/user/services/user-delegate';
@ -33,7 +33,7 @@ export class AddonNotesUserHandlerService implements CoreUserProfileHandler {
name = 'AddonNotes:notes';
priority = 250;
type = CoreUserDelegateService.TYPE_NEW_PAGE;
type = CoreUserProfileHandlerType.LIST_ITEM;
cacheEnabled = true;
/**

View File

@ -18,7 +18,7 @@ import { AddonPrivateFiles } from '@addons/privatefiles/services/privatefiles';
import { makeSingleton } from '@singletons';
import {
CoreUserDelegateContext,
CoreUserDelegateService,
CoreUserProfileHandlerType,
CoreUserProfileHandler,
CoreUserProfileHandlerData,
} from '@features/user/services/user-delegate';
@ -36,7 +36,7 @@ export class AddonPrivateFilesUserHandlerService implements CoreUserProfileHandl
name = 'AddonPrivateFiles';
priority = 400;
type = CoreUserDelegateService.TYPE_NEW_PAGE;
type = CoreUserProfileHandlerType.LIST_ITEM;
cacheEnabled = true;
/**

View File

@ -2,7 +2,7 @@
:host {
--image-size: 120px;
--icon-color: var(--text-color);
--icon-color: var(--subdued-text-color);
display: flex;
flex-direction: column;

View File

@ -0,0 +1,33 @@
// (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 { CoreSharedModule } from '@/core/shared.module';
import { CoreDataPrivacyContactDPOComponent } from './contactdpo/contactdpo';
import { CoreDataPrivacyNewRequestComponent } from './newrequest/newrequest';
@NgModule({
declarations: [
CoreDataPrivacyContactDPOComponent,
CoreDataPrivacyNewRequestComponent,
],
imports: [
CoreSharedModule,
],
exports: [
CoreDataPrivacyContactDPOComponent,
CoreDataPrivacyNewRequestComponent,
],
})
export class CoreDataPrivacyComponentsModule {}

View File

@ -0,0 +1,37 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h1>{{ 'core.dataprivacy.contactdataprotectionofficer' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="close()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fas-xmark" aria-hidden="true" />
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<form [formGroup]="form" name="contactDPO" (ngSubmit)="send($event)">
<ion-item *ngIf="email">
<ion-label>
<p class="item-heading">
{{ 'core.dataprivacy.replyto' | translate }}
</p>
<p>{{ email }}</p>
</ion-label>
</ion-item>
<ion-item>
<ion-textarea labelPlacement="floating" placeholder="{{ 'core.dataprivacy.message' | translate }}" rows="5"
[(ngModel)]="message" name="text" [required]="true" formControlName="message">
<div [core-mark-required]="true" slot="label">
{{ 'core.dataprivacy.message' | translate }}
</div>
</ion-textarea>
</ion-item>
</form>
</ion-content>
<ion-footer slot="fixed" class="ion-padding">
<ion-button expand="block" (click)="send($event)" [disabled]="!form.valid">
{{ 'core.dataprivacy.send' | translate }}
</ion-button>
</ion-footer>

View File

@ -0,0 +1,94 @@
// (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 { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { CoreDataPrivacy } from '@features/dataprivacy/services/dataprivacy';
import { CoreUser } from '@features/user/services/user';
import { CoreSites } from '@services/sites';
import { CoreDomUtils, ToastDuration } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { ModalController } from '@singletons';
/**
* Component that displays the contact DPO page.
*/
@Component({
selector: 'core-data-privacy-contact-dpo',
templateUrl: 'contactdpo.html',
})
export class CoreDataPrivacyContactDPOComponent implements OnInit {
message = '';
email = '';
// Form variables.
form: FormGroup;
constructor(
protected fb: FormBuilder,
) {
this.form = new FormGroup({});
// Initialize form variables.
this.form.addControl('message', this.fb.control('', Validators.required));
}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
const modal = await CoreDomUtils.showModalLoading();
// Get current user email.
const userId = CoreSites.getCurrentSiteUserId();
const user = await CoreUtils.ignoreErrors(CoreUser.getProfile(userId));
this.email = user?.email || '';
modal.dismiss();
}
/**
* Sends the message.
*/
async send(event: Event): Promise<void> {
event.preventDefault();
event.stopPropagation();
const modal = await CoreDomUtils.showModalLoading();
try {
// Send the message.
const succeed = await CoreDataPrivacy.contactDPO(this.message);
if (succeed) {
CoreDomUtils.showToast('core.dataprivacy.requestsubmitted', true, ToastDuration.LONG);
ModalController.dismiss(true);
}
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error sending data privacy request');
} finally {
modal.dismiss();
}
}
/**
* Close modal.
*/
close(): void {
ModalController.dismiss();
}
}

View File

@ -0,0 +1,50 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h1>{{ 'core.dataprivacy.createnewdatarequest' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="close()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fas-xmark" aria-hidden="true" />
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<form [formGroup]="form" name="newRequest" (ngSubmit)="send($event)">
<ion-item class="ion-text-wrap">
<ion-label>
<p>
{{ 'core.dataprivacy.requesttype_help' | translate }}
</p>
<p class="item-heading" [core-mark-required]="true">
{{ 'core.dataprivacy.requesttype' | translate }}
</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-radio-group name="type" formControlName="type">
<ion-radio [value]="1" *ngIf="accessInfo?.cancreatedatadownloadrequest">
{{ 'core.dataprivacy.requesttypeexport' | translate }}
</ion-radio>
<ion-radio [value]="2" *ngIf="accessInfo?.cancreatedatadeletionrequest">
{{ 'core.dataprivacy.requesttypedelete' | translate }}
</ion-radio>
</ion-radio-group>
</ion-item>
<ion-item>
<ion-textarea labelPlacement="stacked" placeholder="{{ 'core.dataprivacy.requestcomments' | translate }}" rows="5"
[(ngModel)]="message" name="text" formControlName="message">
<div slot="label">
{{ 'core.dataprivacy.requestcomments' | translate }}
</div>
</ion-textarea>
</ion-item>
</form>
</ion-content>
<ion-footer slot="fixed" class="ion-padding">
<ion-button expand="block" (click)="send($event)" [disabled]="!form.valid">
{{ 'core.dataprivacy.send' | translate }}
</ion-button>
</ion-footer>

View File

@ -0,0 +1,104 @@
// (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, Input, OnInit } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import {
CoreDataPrivacy,
CoreDataPrivacyDataRequestType,
CoreDataPrivacyGetAccessInformationWSResponse,
} from '@features/dataprivacy/services/dataprivacy';
import { CoreDomUtils, ToastDuration } from '@services/utils/dom';
import { ModalController } from '@singletons';
/**
* Component that displays the new request page.
*/
@Component({
selector: 'core-data-privacy-new-request',
templateUrl: 'newrequest.html',
})
export class CoreDataPrivacyNewRequestComponent implements OnInit {
@Input() accessInfo?: CoreDataPrivacyGetAccessInformationWSResponse;
message = '';
// Form variables.
form: FormGroup;
typeControl: FormControl<CoreDataPrivacyDataRequestType>;
constructor(
protected fb: FormBuilder,
) {
this.form = new FormGroup({});
// Initialize form variables.
this.typeControl = this.fb.control(
CoreDataPrivacyDataRequestType.DATAREQUEST_TYPE_EXPORT,
{ validators: Validators.required, nonNullable: true },
);
this.form.addControl('type', this.typeControl);
this.form.addControl('message', this.fb.control(''));
}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
// It should not happen. If there's no access info, close the modal.
if (!this.accessInfo) {
ModalController.dismiss();
return;
}
// Just in case only deleting is allowed, change the default type.
if (!this.accessInfo.cancreatedatadownloadrequest && this.accessInfo.cancreatedatadeletionrequest){
this.typeControl.setValue(CoreDataPrivacyDataRequestType.DATAREQUEST_TYPE_DELETE);
}
}
/**
* Sends the request.
*/
async send(event: Event): Promise<void> {
event.preventDefault();
event.stopPropagation();
const modal = await CoreDomUtils.showModalLoading();
try {
// Send the message.
const requestId = await CoreDataPrivacy.createDataRequest(this.typeControl.value, this.message);
if (requestId) {
CoreDomUtils.showToast('core.dataprivacy.requestsubmitted', true, ToastDuration.LONG);
ModalController.dismiss(true);
}
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error sending data privacy request');
} finally {
modal.dismiss();
}
}
/**
* Close modal.
*/
close(): void {
ModalController.dismiss();
}
}

View File

@ -0,0 +1,16 @@
// (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.
// Routing.
export const CORE_DATAPRIVACY_PAGE_NAME = 'dataprivacy';

View File

@ -0,0 +1,38 @@
// (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 { CoreSharedModule } from '@/core/shared.module';
import { CoreDataPrivacyMainPage } from './pages/main/main';
const routes: Routes = [
{
path: '',
pathMatch: 'full',
component: CoreDataPrivacyMainPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CoreSharedModule,
],
declarations: [
CoreDataPrivacyMainPage,
],
})
export class CoreDataPrivacyLazyModule {}

View File

@ -0,0 +1,45 @@
// (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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreUserDelegate } from '@features/user/services/user-delegate';
import { CoreDataPrivacyUserHandler } from './services/handlers/user';
import { Routes } from '@angular/router';
import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
import { CoreDataPrivacyComponentsModule } from './components/components.module';
import { CORE_DATAPRIVACY_PAGE_NAME } from './constants';
const routes: Routes = [
{
path: CORE_DATAPRIVACY_PAGE_NAME,
loadChildren: () => import('./dataprivacy-lazy.module').then(m => m.CoreDataPrivacyLazyModule),
},
];
@NgModule({
imports: [
CoreMainMenuTabRoutingModule.forChild(routes),
CoreDataPrivacyComponentsModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
useValue: () => {
CoreUserDelegate.registerHandler(CoreDataPrivacyUserHandler.instance);
},
},
],
})
export class CoreDataPrivacyModule {}

View File

@ -0,0 +1,36 @@
{
"contactdataprotectionofficer": "Contact the privacy officer",
"cancelrequest": "Cancel request",
"cancelrequestconfirmation": "Do you really want cancel this data request?",
"createnewdatarequest": "Create a new data request",
"datarequests": "Data requests",
"daterequested": "Date requested",
"deletemyaccount": "Delete my account",
"message": "Message",
"newrequest": "New request",
"nodatarequests": "There are no data requests",
"pluginname": "Data privacy",
"replyto": "Reply to",
"requestactions": "Actions",
"requestby": "Requested by",
"requestcomments": "Comments",
"requeststatus": "Status",
"requestsubmitted": "Your request has been submitted to the privacy officer",
"requesttype_help": "Select the reason for contacting the privacy officer. Be aware that deletion of all personal data will result in you no longer being able to log in to the site.",
"requesttype": "Type",
"requesttypedelete": "Delete all of my personal data",
"requesttypeexport": "Export all of my personal data",
"requesttypeothers": "General enquiry",
"send": "Send",
"statusapproved": "Approved",
"statusawaitingapproval": "Awaiting approval",
"statuscancelled": "Cancelled",
"statuscomplete": "Complete",
"statusdeleted": "Deleted",
"statusexpired": "Expired",
"statuspending": "Pending",
"statuspreprocessing": "Pre-processing",
"statusprocessing": "Processing",
"statusready": "Download ready",
"statusrejected": "Rejected"
}

View File

@ -0,0 +1,160 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ 'core.dataprivacy.pluginname' | translate }}</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshContent($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ion-item-divider class="ion-text-wrap">
<ion-label>
<h2 class="big">{{ 'core.dataprivacy.datarequests' | translate }}</h2>
</ion-label>
</ion-item-divider>
<ion-list *ngIf="requests.length && !isTablet">
<ion-card *ngFor=" let request of requests">
<ion-item class="ion-text-wrap">
<ion-label>
<p class="item-heading">
<ng-container *ngTemplateOutlet="type; context: {request: request}" />
</p>
<ion-row class="ion-justify-content-between ion-no-padding">
<ion-col class="ion-no-padding">
<p>{{request.timecreated * 1000 | coreFormatDate }}</p>
</ion-col>
<ion-col class="core-flex-no-grow ion-text-end ion-no-padding">
<ng-container *ngTemplateOutlet="statusBadge; context: {request: request}" />
</ion-col>
</ion-row>
</ion-label>
</ion-item>
<ion-item button [detail]="false" *ngIf="request.requestedbyuser" core-user-link [userId]="request.requestedbyuser.id"
[attr.aria-label]="request.requestedbyuser.fullname" class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ 'core.dataprivacy.requestby' | translate }}</p>
<p>{{ request.requestedbyuser.fullname }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" [lines]="request.canCancel ? 'full' : 'none'">
<ion-label>
<p class="item-heading">{{ 'core.dataprivacy.message' | translate }}</p>
<p><core-format-text [text]="request.messagehtml" /></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap ion-text-end" *ngIf="request.canCancel">
<ion-label>
<ion-button fill="outline" expand="block" (click)="cancelRequest(request.id)">
{{ 'core.dataprivacy.cancelrequest' | translate }}
</ion-button>
</ion-label>
</ion-item>
</ion-card>
</ion-list>
<table *ngIf="requests.length && isTablet" class="core-table">
<thead>
<tr>
<th>{{ 'core.dataprivacy.requesttype' | translate }}</th>
<th>{{ 'core.dataprivacy.daterequested' | translate }}</th>
<th class="shrink">{{ 'core.dataprivacy.requestby' | translate }}</th>
<th class="shrink">{{ 'core.dataprivacy.requeststatus' | translate }}</th>
<th>{{ 'core.dataprivacy.message' | translate }}</th>
<th class=" shrink">{{ 'core.dataprivacy.requestactions' | translate }}</th>
</tr>
</thead>
<tbody class="auto-striped">
<tr *ngFor=" let request of requests">
<td>
<p><ng-container *ngTemplateOutlet="type; context: {request: request}" /></p>
</td>
<td>
<p>{{request.timecreated * 1000 | coreFormatDate }}</p>
</td>
<td>
<p>{{ request.requestedbyuser.fullname }}</p>
</td>
<td>
<ng-container *ngTemplateOutlet="statusBadge; context: {request: request}" />
</td>
<td>
<p><core-format-text [text]="request.messagehtml" /></p>
</td>
<td>
<ion-button fill="outline" size="small" (click)="cancelRequest(request.id)" *ngIf="request.canCancel">
{{ 'core.dataprivacy.cancelrequest' | translate }}
</ion-button>
</td>
</tr>
</tbody>
</table>
<core-empty-box *ngIf="!requests.length" icon="fas-bell-concierge" [message]="'core.dataprivacy.nodatarequests' | translate" />
<div collapsible-footer *ngIf="loaded" slot="fixed">
<div class="list-item-limited-width adaptable-buttons-row">
<ion-button class="ion-margin ion-text-wrap" expand="block" (click)="contactDPO()" *ngIf="accessInfo?.cancontactdpo"
fill="outline">
<ion-icon slot="start" name="fas-envelope" [attr.aria-hidden]="true" />
{{ 'core.dataprivacy.contactdataprotectionofficer' | translate }}
</ion-button>
<ion-button class="ion-margin ion-text-wrap" expand="block" (click)="newRequest()"
*ngIf="accessInfo?.cancreatedatadownloadrequest || accessInfo?.cancreatedatadeletionrequest">
<ion-icon slot="start" name="fas-pen-to-square" [attr.aria-hidden]="true" />
{{ 'core.dataprivacy.newrequest' | translate }}
</ion-button>
</div>
</div>
</core-loading>
</ion-content>
<ng-template #statusBadge let-request="request">
@switch (request.status) {
@case (0) {
<ion-badge color="info">{{'core.dataprivacy.statuspending' | translate }}</ion-badge>
} @case (1) {
<ion-badge color="info">{{'core.dataprivacy.statuspreprocessing' | translate }}</ion-badge>
} @case (2) {
<ion-badge color="info">{{'core.dataprivacy.statusawaitingapproval' | translate }}</ion-badge>
} @case (3) {
<ion-badge color="info">{{'core.dataprivacy.statusapproved' | translate }}</ion-badge>
} @case (4) {
<ion-badge color="info">{{'core.dataprivacy.statusprocessing' | translate }}</ion-badge>
} @case (5) {
<ion-badge color="success">{{'core.dataprivacy.statuscomplete' | translate }}</ion-badge>
} @case (6) {
<ion-badge color="warning">{{'core.dataprivacy.statuscancelled' | translate }}</ion-badge>
} @case (7) {
<ion-badge color="danger">{{'core.dataprivacy.statusrejected' | translate }}</ion-badge>
} @case (8) {
<ion-badge color="success">{{'core.dataprivacy.statusready' | translate }}</ion-badge>
} @case (9) {
<ion-badge color="secondary">{{'core.dataprivacy.statusexpired' | translate }}</ion-badge>
} @case (10) {
<ion-badge color="success">{{'core.dataprivacy.statusdeleted' | translate }}</ion-badge>
} @default {
<ion-badge class="ion-text-wrap">{{request.statuslabel}}</ion-badge>
}
}
</ng-template>
<ng-template #type let-request="request">
@switch (request.type) {
@case (1) {
{{ 'core.dataprivacy.requesttypeexport' | translate }}
} @case (2) {
{{ 'core.dataprivacy.requesttypedelete' | translate }}
} @case (3) {
{{ 'core.dataprivacy.requesttypeothers' | translate }}
} @default {
{{request.typename}}
}
}
</ng-template>

View File

@ -0,0 +1,10 @@
table {
th {
width: 20%;
}
th.shrink {
width: 1%;
}
}

View File

@ -0,0 +1,167 @@
// (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 { CoreDataPrivacyContactDPOComponent } from '@features/dataprivacy/components/contactdpo/contactdpo';
import { CoreDataPrivacyNewRequestComponent } from '@features/dataprivacy/components/newrequest/newrequest';
import {
CoreDataPrivacy,
CoreDataPrivacyGetAccessInformationWSResponse,
CoreDataPrivacyRequest,
} from '@features/dataprivacy/services/dataprivacy';
import { CoreScreen } from '@services/screen';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
import { Subscription } from 'rxjs';
/**
* Page to display the main data privacy page.
*/
@Component({
selector: 'page-core-data-privacy-main',
templateUrl: 'main.html',
styleUrl: 'main.scss',
})
export class CoreDataPrivacyMainPage implements OnInit {
accessInfo?: CoreDataPrivacyGetAccessInformationWSResponse;
requests: CoreDataPrivacyRequestToDisplay[] = [];
loaded = false;
isTablet = false;
layoutSubscription?: Subscription;
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
this.fetchContent();
this.isTablet = CoreScreen.isTablet;
this.layoutSubscription = CoreScreen.layoutObservable.subscribe(() => {
this.isTablet = CoreScreen.isTablet;
});
}
/**
* Fetch page content.
*/
async fetchContent(): Promise<void> {
try {
this.accessInfo = await CoreDataPrivacy.getAccessInformation();
this.requests = await CoreDataPrivacy.getDataRequests();
this.requests.forEach((request) => {
request.canCancel = CoreDataPrivacy.canCancelRequest(request);
});
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error fetching data privacy information', true);
} finally {
this.loaded = true;
}
}
/**
* Refresh the page content.
*
* @param refresher Refresher.
*/
async refreshContent(refresher?: HTMLIonRefresherElement): Promise<void> {
await CoreUtils.ignoreErrors(
CoreDataPrivacy.invalidateAll(),
);
await CoreUtils.ignoreErrors(this.fetchContent());
refresher?.complete();
}
/**
* Open the contact DPO modal.
*/
async contactDPO(): Promise<void> {
// Create and show the modal.
const succeed = await CoreDomUtils.openModal<boolean>({
component: CoreDataPrivacyContactDPOComponent,
});
if (succeed) {
const modal = await CoreDomUtils.showModalLoading();
try {
await this.refreshContent();
} finally {
modal.dismiss();
}
}
}
/**
* Open the new request modal.
*/
async newRequest(): Promise<void> {
// Create and show the modal.
const succeed = await CoreDomUtils.openModal<boolean>({
component: CoreDataPrivacyNewRequestComponent,
componentProps: {
accessInfo: this.accessInfo,
},
});
if (succeed) {
const modal = await CoreDomUtils.showModalLoading();
try {
await this.refreshContent();
} finally {
modal.dismiss();
}
}
}
/**
* Cancel a request.
*
* @param requestId Request ID.
*/
async cancelRequest(requestId: number): Promise<void> {
try {
await CoreDomUtils.showConfirm(
Translate.instant('core.dataprivacy.cancelrequestconfirmation'),
Translate.instant('core.dataprivacy.cancelrequest'),
Translate.instant('core.dataprivacy.cancelrequest'),
);
} catch {
return;
}
const modal = await CoreDomUtils.showModalLoading();
try {
await CoreDataPrivacy.cancelDataRequest(requestId);
await this.refreshContent();
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error cancelling data privacy request');
} finally {
modal.dismiss();
}
}
}
type CoreDataPrivacyRequestToDisplay = CoreDataPrivacyRequest & {
canCancel?: boolean;
};

View File

@ -0,0 +1,378 @@
// (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 { CoreWSError } from '@classes/errors/wserror';
import { CoreSiteWSPreSets } from '@classes/sites/authenticated-site';
import { CoreUserSummary } from '@features/user/services/user';
import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites';
import { CoreWSExternalWarning } from '@services/ws';
import { makeSingleton } from '@singletons';
/**
* Service to handle data privacy.
*/
@Injectable({ providedIn: 'root' })
export class CoreDataPrivacyService {
static readonly ROOT_CACHE_KEY = 'CoreDataPrivacy:';
/**
* Check if data privacy is enabled on current site.
*
* @returns Whether data privacy is enabled.
*/
async isEnabled(): Promise<boolean> {
const site = CoreSites.getCurrentSite();
// Check if the privacy data WS are available in the site.
if (!site?.wsAvailable('tool_dataprivacy_get_data_requests')) {
return false;
}
// If the user can contact the DPO, then data privacy is enabled.
const accessInformation = await this.getAccessInformation();
return accessInformation.cancontactdpo;
}
/**
* Get cache key for data privacy access information WS calls.
*
* @returns Cache key.
*/
protected getAccessInformationCacheKey(): string {
return CoreDataPrivacyService.ROOT_CACHE_KEY + 'accessInformation';
}
/**
* Retrieving privacy API access (permissions) information for the current user.
*
* @param options Request options.
* @returns Promise resolved with object with access information.
* @since 4.4
*/
async getAccessInformation(
options: CoreSitesCommonWSOptions = {},
): Promise<CoreDataPrivacyGetAccessInformationWSResponse> {
const site = await CoreSites.getSite(options.siteId);
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getAccessInformationCacheKey(),
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('tool_dataprivacy_get_access_information', undefined, preSets);
}
/**
* Invalidates access information.
*
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when the data is invalidated.
*/
protected async invalidateAccessInformation(siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
await site.invalidateWsCacheForKey(this.getAccessInformationCacheKey());
}
/**
* Contact the site Data Protection Officer(s).
*
* @param message Message to send to the DPO.
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved with boolean: whether the message was sent.
* @since 4.4
*/
async contactDPO(message: string, siteId?: string): Promise<boolean> {
const site = await CoreSites.getSite(siteId);
const params: CoreDataPrivacyContactDPOWSParams = { message };
const response = await site.write<CoreDataPrivacyContactDPOWSResponse>('tool_dataprivacy_contact_dpo', params);
if (response.warnings && response.warnings.length) {
throw new CoreWSError(response.warnings[0]);
}
return response.result;
}
/**
* Get cache key for data requests WS calls.
*
* @returns Cache key.
*/
protected getDataRequestsCacheKey(): string {
return CoreDataPrivacyService.ROOT_CACHE_KEY + 'datarequests';
}
/**
* Fetch the details of a user's data request.
*
* @param options Request options.
* @returns Promise resolved with the data requests.
* @since 4.4
*/
async getDataRequests(
options: CoreSitesCommonWSOptions = {},
): Promise<CoreDataPrivacyRequest[]> {
const site = await CoreSites.getSite(options.siteId);
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getDataRequestsCacheKey(),
...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
const params: CoreDataPrivacyGetDataRequestsWSParams = {
userid: site.getUserId(),
};
const response =
await site.read<CoreDataPrivacyGetDataRequestsWSResponse>('tool_dataprivacy_get_data_requests', params, preSets);
return response.requests;
}
/**
* Invalidate data requests.
*
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when the data is invalidated.
*/
async invalidateDataRequests(siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
await site.invalidateWsCacheForKey(this.getDataRequestsCacheKey());
}
/**
* Creates a data request.
*
* @param type Type of the request.
* @param comments Comments for the data request.
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when the request is created.
* @since 4.4
*/
async createDataRequest(type: CoreDataPrivacyDataRequestType, comments: string, siteId?: string): Promise<number> {
const site = await CoreSites.getSite(siteId);
const params: CoreDataPrivacyCreateDataRequestWSParams = {
type,
comments,
};
const response =
await site.write<CoreDataPrivacyCreateDataRequestWSResponse>('tool_dataprivacy_create_data_request', params);
if (response.warnings && response.warnings.length) {
throw new CoreWSError(response.warnings[0]);
}
return response.datarequestid;
}
/**
* Cancel the data request made by the user.
*
* @param requestid ID of the request to cancel.
* @param siteId Site ID. If not defined, current site.
* @returns Promise resolved with boolean: whether the request was canceled.
* @since 4.4
*/
async cancelDataRequest(requestid: number, siteId?: string): Promise<boolean> {
const site = await CoreSites.getSite(siteId);
const params: CoreDataPrivacyCancelDataRequestWSParams = { requestid };
const response =
await site.write<CoreDataPrivacyCancelDataRequestWSResponse>('tool_dataprivacy_cancel_data_request', params);
if (response.warnings && response.warnings.length) {
throw new CoreWSError(response.warnings[0]);
}
return response.result;
}
/**
* Invalidate all the data related to data privacy.
*/
async invalidateAll(): Promise<void> {
await Promise.all([
this.invalidateAccessInformation(),
this.invalidateDataRequests(),
]);
}
/**
* Check if the user can cancel a request.
*
* @param request The request to check.
* @returns Whether the user can cancel the request.
*/
canCancelRequest(request: CoreDataPrivacyRequest): boolean {
const cannotCancelStatuses = [
CoreDataPrivacyDataRequestStatus.DATAREQUEST_STATUS_COMPLETE,
CoreDataPrivacyDataRequestStatus.DATAREQUEST_STATUS_DOWNLOAD_READY,
CoreDataPrivacyDataRequestStatus.DATAREQUEST_STATUS_DELETED,
CoreDataPrivacyDataRequestStatus.DATAREQUEST_STATUS_EXPIRED,
CoreDataPrivacyDataRequestStatus.DATAREQUEST_STATUS_CANCELLED,
CoreDataPrivacyDataRequestStatus.DATAREQUEST_STATUS_REJECTED,
];
return !cannotCancelStatuses.includes(request.status);
}
}
export const CoreDataPrivacy = makeSingleton(CoreDataPrivacyService);
export enum CoreDataPrivacyDataRequestType {
DATAREQUEST_TYPE_EXPORT = 1, // Data export request type.
DATAREQUEST_TYPE_DELETE = 2, // Data deletion request type.
DATAREQUEST_TYPE_OTHERS = 3, // Other request type. Usually of enquiries to the DPO.
}
export enum CoreDataPrivacyDataRequestStatus {
DATAREQUEST_STATUS_PENDING = 0, // Newly submitted and we haven't yet started finding out where they have data.
DATAREQUEST_STATUS_PREPROCESSING = 1, // Newly submitted and we have started to find the location of data.
DATAREQUEST_STATUS_AWAITING_APPROVAL = 2, // Metadata ready and awaiting review and approval by the Data Protection officer.
DATAREQUEST_STATUS_APPROVED = 3, // Request approved and will be processed soon.
DATAREQUEST_STATUS_PROCESSING = 4, // The request is now being processed.
DATAREQUEST_STATUS_COMPLETE = 5, // Information/other request completed.
DATAREQUEST_STATUS_CANCELLED = 6, // Data request cancelled by the user.
DATAREQUEST_STATUS_REJECTED = 7, // Data request rejected by the DPO.
DATAREQUEST_STATUS_DOWNLOAD_READY = 8, // Data request download ready.
DATAREQUEST_STATUS_EXPIRED = 9, // Data request expired.
DATAREQUEST_STATUS_DELETED = 10, // Data delete request completed, account is removed.
}
/**
* Data returned by tool_dataprivacy_get_access_information WS.
*/
export type CoreDataPrivacyGetAccessInformationWSResponse = {
cancontactdpo: boolean; // Can contact dpo.
canmanagedatarequests: boolean; // Can manage data requests.
cancreatedatadownloadrequest: boolean; // Can create data download request for self.
cancreatedatadeletionrequest: boolean; // Can create data deletion request for self.
hasongoingdatadownloadrequest: boolean; // Has ongoing data download request.
hasongoingdatadeletionrequest: boolean; // Has ongoing data deletion request.
warnings?: CoreWSExternalWarning[];
};
/**
* Params of tool_dataprivacy_contact_dpo WS.
*/
type CoreDataPrivacyContactDPOWSParams = {
message: string; // The user's message to the Data Protection Officer(s).
};
/**
* Data returned by tool_dataprivacy_contact_dpo WS.
*/
type CoreDataPrivacyContactDPOWSResponse = {
result: boolean; // The processing result
warnings?: CoreWSExternalWarning[];
};
/**
* Params of tool_dataprivacy_create_data_request WS.
*/
type CoreDataPrivacyCreateDataRequestWSParams = {
type: CoreDataPrivacyDataRequestType; // The type of data request to create. 1 for export, 2 for data deletion.
comments?: string; // Comments for the data request.
foruserid?: number; // The id of the user to create the data request for. Empty for current user.
};
/**
* Data returned by tool_dataprivacy_create_data_request WS.
*/
type CoreDataPrivacyCreateDataRequestWSResponse = {
datarequestid: number; // The id of the created data request.
warnings?: CoreWSExternalWarning[];
};
/**
* Params of tool_dataprivacy_cancel_data_request WS.
*/
type CoreDataPrivacyCancelDataRequestWSParams = {
requestid: number; // The request ID
};
/**
* Data returned by tool_dataprivacy_cancel_data_request WS.
*/
type CoreDataPrivacyCancelDataRequestWSResponse = {
result: boolean; // The processing result
warnings?: CoreWSExternalWarning[];
};
/**
* Params of tool_dataprivacy_get_data_requests WS.
*/
type CoreDataPrivacyGetDataRequestsWSParams = {
userid?: number; // The id of the user to get the data requests for. Empty for all users.
statuses?: CoreDataPrivacyDataRequestStatus[]; // The statuses of the data requests to get.
// 0 for pending 1 preprocessing, 2 awaiting approval, 3 approved,
// 4 processed, 5 completed, 6 cancelled, 7 rejected.
types?: number[]; // The types of the data requests to get. 1 for export, 2 for data deletion.
creationmethods?: number[]; // The creation methods of the data requests to get. 0 for manual, 1 for automatic.
sort?: string; // The field to sort the data requests by.
limitfrom?: number; // The number to start getting the data requests from.
limitnum?: number; // The number of data requests to get.
};
/**
* Data returned by tool_dataprivacy_get_data_requests WS.
*/
type CoreDataPrivacyGetDataRequestsWSResponse = {
requests: CoreDataPrivacyRequest[]; // The data requests.
warnings?: CoreWSExternalWarning[];
};
/**
* Data for the dataprivacy request.
*/
export type CoreDataPrivacyRequest = {
type: CoreDataPrivacyDataRequestType; // Type.
comments: string; // Comments.
commentsformat: number; // Commentsformat.
userid: number; // Userid.
requestedby: number; // Requestedby.
status: CoreDataPrivacyDataRequestStatus; // Status.
dpo: number; // Dpo.
dpocomment: string; // Dpocomment.
dpocommentformat: number; // Dpocommentformat.
systemapproved: boolean; // Systemapproved.
creationmethod: number; // Creationmethod.
id: number; // Id.
timecreated: number; // Timecreated.
timemodified: number; // Timemodified.
usermodified: number; // Usermodified.
foruser: CoreUserSummary; // The user the request is for.
requestedbyuser: CoreUserSummary; // The user who requested the data.
dpouser?: CoreUserSummary; // The user who processed the request.
messagehtml?: string; // Messagehtml.
typename: string; // Typename.
typenameshort: string; // Typenameshort.
statuslabel: string; // Statuslabel.
statuslabelclass: string; // Statuslabelclass.
canreview?: boolean; // Canreview.
approvedeny?: boolean; // Approvedeny.
allowfiltering?: boolean; // Allowfiltering.
canmarkcomplete?: boolean; // Canmarkcomplete.
};

View File

@ -0,0 +1,63 @@
// (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 {
CoreUserProfileHandlerType,
CoreUserProfileHandler,
CoreUserProfileHandlerData,
} from '@features/user/services/user-delegate';
import { CoreNavigator } from '@services/navigator';
import { makeSingleton } from '@singletons';
import { CoreDataPrivacy } from '../dataprivacy';
import { CORE_DATAPRIVACY_PAGE_NAME } from '@features/dataprivacy/constants';
/**
* Handler to visualize custom reports.
*/
@Injectable({ providedIn: 'root' })
export class CoreDataPrivacyUserHandlerService implements CoreUserProfileHandler {
protected pageName = CORE_DATAPRIVACY_PAGE_NAME;
type = CoreUserProfileHandlerType.LIST_ACCOUNT_ITEM;
name = 'CoreDataPrivacyDelegate';
priority = 100;
/**
* @inheritdoc
*/
async isEnabled(): Promise<boolean> {
return await CoreDataPrivacy.isEnabled();
}
/**
* @inheritdoc
*/
getDisplayData(): CoreUserProfileHandlerData {
return {
class: 'core-data-privacy',
icon: 'fas-user-shield',
title: 'core.dataprivacy.pluginname',
action: async (event): Promise<void> => {
event.preventDefault();
event.stopPropagation();
await CoreNavigator.navigateToSitePath(this.pageName);
},
};
}
}
export const CoreDataPrivacyUserHandler = makeSingleton(CoreDataPrivacyUserHandlerService);

View File

@ -0,0 +1,27 @@
@core_dataprivacy @app @javascript @lms_from4.4
Feature: Contact the privacy officer
As a user
In order to reach out to the site's privacy officer
I need to be able to contact the site's privacy officer in Moodle
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| student1 | Student | 1 | s1@example.com |
Scenario: Contacting the privacy officer
Given the following config values are set as admin:
| contactdataprotectionofficer | 1 | tool_dataprivacy |
When I entered the app as "student1"
And I press the user menu button in the app
And I press "Data privacy" in the app
And I press "Contact the privacy officer" in the app
And I set the field "Message" to "Hello DPO!" in the app
And I press "Send" in the app
Then I should find "Your request has been submitted to the privacy officer" in the app
And I should find "Hello DPO!" in the app
Scenario: Contacting the privacy officer when not enabled
When I entered the app as "student1"
And I press the user menu button in the app
Then I should not find ""Data privacy" in the app

View File

@ -0,0 +1,62 @@
@core_dataprivacy @app @javascript @lms_from4.4
Feature: Data export and delete from the privacy API
In order to export or delete data for users and meet legal requirements
I need to be able to request my data data be exported or deleted
Background:
Given the following "users" exist:
| username | firstname | lastname |
| victim | Victim User | 1 |
And the following config values are set as admin:
| contactdataprotectionofficer | 1 | tool_dataprivacy |
| privacyrequestexpiry | 55 | tool_dataprivacy |
| dporoles | 1 | tool_dataprivacy |
Scenario: As a student, request deletion of account and data
Given I entered the app as "victim"
And I press the user menu button in the app
And I press "Data privacy" in the app
And I press "New request" in the app
And I press "Delete all of my personal data" in the app
And I press "Send" in the app
Then I should find "Delete all of my personal data" in the app
And I should find "Awaiting approval" near "Delete all of my personal data" in the app
Scenario: As a student, I cannot create data deletion request unless I have permission.
Given the following "permission overrides" exist:
| capability | permission | role | contextlevel | reference |
| tool/dataprivacy:requestdelete | Prevent | user | System | |
And I entered the app as "victim"
And I press the user menu button in the app
And I press "Data privacy" in the app
When I press "New request" in the app
Then I should not find "Delete all of my personal data" in the app
Scenario: As a student, request data export and then see the status
Given I entered the app as "victim"
And I press the user menu button in the app
And I press "Data privacy" in the app
When I press "New request" in the app
And I press "Export all of my personal data" in the app
And I set the field "Comments" to "Export my data" in the app
And I press "Send" in the app
Then I should find "Export all of my personal data" in the app
And I should find "Awaiting approval" near "Export all of my personal data" in the app
And I should find "Export my data" near "Export all of my personal data" in the app
# The next step allows to naavigate to site administration
When I change viewport size to "1200x640" in the app
And I open a browser tab with url "$WWWROOT"
And I log in as "admin"
And I navigate to "Users > Privacy and policies > Data requests" in site administration
And I open the action menu in "Victim User 1" "table_row"
And I follow "Approve request"
And I press "Approve request"
And I switch back to the app
And I pull to refresh in the app
Then I should find "Approved" near "Export all of my personal data" in the app
When I run all adhoc tasks
And I pull to refresh in the app
And I should find "Download ready" near "Export all of my personal data" in the app
# TODO: Add download link and test it.

View File

@ -0,0 +1,24 @@
@core_dataprivacy @app @javascript @lms_from4.4
Feature: Manage my own data requests
In order to manage my own data requests
As a user
I need to be able to view and cancel all my data requests
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| student1 | Student | 1 | s1@example.com |
And the following config values are set as admin:
| contactdataprotectionofficer | 1 | tool_dataprivacy |
Scenario: Cancel my own data request
Given I entered the app as "student1"
And I press the user menu button in the app
And I press "Data privacy" in the app
And I press "Contact the privacy officer" in the app
And I set the field "Message" to "Hello DPO!" in the app
And I press "Send" in the app
Then I should find "Your request has been submitted to the privacy officer" in the app
When I press "Cancel" near "Hello DPO!" in the app
And I press "Cancel request" "button" in the app
Then I should find "Cancelled" near "Hello DPO!" in the app

View File

@ -24,8 +24,6 @@ export const CORE_EDITOR_SERVICES: Type<unknown>[] = [
];
@NgModule({
declarations: [
],
imports: [
CoreEditorComponentsModule,
],

View File

@ -19,6 +19,7 @@ import { CoreCommentsModule } from './comments/comments.module';
import { CoreContentLinksModule } from './contentlinks/contentlinks.module';
import { CoreCourseModule } from './course/course.module';
import { CoreCoursesModule } from './courses/courses.module';
import { CoreDataPrivacyModule } from './dataprivacy/dataprivacy.module';
import { CoreEditorModule } from './editor/editor.module';
import { CoreEmulatorModule } from './emulator/emulator.module';
import { CoreEnrolModule } from './enrol/enrol.module';
@ -53,6 +54,7 @@ import { CoreReportBuilderModule } from './reportbuilder/reportbuilder.module';
CoreContentLinksModule,
CoreCourseModule,
CoreCoursesModule,
CoreDataPrivacyModule,
CoreEditorModule,
CoreEnrolModule,
CoreFileUploaderModule,

View File

@ -15,7 +15,7 @@
<core-loading [hideUntil]="loaded">
<core-empty-box *ngIf="!rows.length" icon="fas-chart-bar" [message]="'core.grades.nogradesreturned' | translate" />
<div *ngIf="rows.length" class="core-grades-container">
<table class="core-grades-table" [class.summary]="showSummary">
<table class="core-table core-grades-table" [class.summary]="showSummary">
<thead>
<tr>
<th *ngFor="let column of columns" id="{{column.name}}" class="ion-text-start"

View File

@ -1,67 +1,22 @@
@use "theme/globals" as *;
:host {
--header-background: var(--white);
--odd-cell-background: var(--light);
--odd-cell-hover: var(--gray-200);
--even-cell-background: var(--white);
--even-cell-hover: var(--light);
--icon-color: var(--gray-500);
--border-color: var(--stroke);
.odd {
--cell-background: var(--odd-cell-background);
--cell-hover: var(--odd-cell-hover);
}
.even {
--cell-background: var(--even-cell-background);
--cell-hover: var(--even-cell-hover);
}
--core-table-border-color: var(--stroke);
}
:host-context(html.dark) {
--header-background: var(--gray-900);
--odd-cell-background: var(--gray-800);
--odd-cell-hover: var(--gray-600);
--even-cell-background: var(--gray-900);
--even-cell-hover: var(--gray-700);
--icon-color: var(--gray-200);
}
.core-grades-table {
border-collapse: collapse;
line-height: 20px;
table.core-table.core-grades-table {
width: 100%;
font-size: 16px;
color: var(--ion-text-color);
tr {
border-bottom: 1px solid var(--border-color);
}
th, td {
@include padding(8px, 8px, 8px, null);
vertical-align: top;
white-space: normal;
text-align: start;
}
thead th {
vertical-align: bottom;
font-weight: bold;
background-color: var(--header-background);
}
margin: 0px;
thead #gradeitem {
@include padding(null, null, null, 24px);
}
tbody th {
font-weight: normal;
}
tbody tr.core-bold th {
font-weight: inherit;
}
@ -70,9 +25,13 @@
@include padding(null, null, null, 4px);
}
th, td {
@include padding(8px, 8px, 8px, 0px);
vertical-align: top;
}
.core-grades-table-gradeitem {
@include padding(null, null, null, 4px);
font-weight: bold;
&.column-itemname {
@include padding(null, null, null, 0);
@ -90,7 +49,6 @@
--filter: var(--module-icon-filter);
}
ion-icon {
color: var(--icon-color);
}
@ -130,21 +88,6 @@
width: 0;
}
.ion-no-border {
border: 0 !important;
}
.dimmed_text,
.hidden {
opacity: .7;
}
.odd, .even {
td, th, th[aria-current="page"] {
background-color: var(--cell-background);
}
}
.core-grades-grade-clickable {
cursor: pointer;
&:hover {

View File

@ -19,7 +19,7 @@ import { CoreGrades } from '@features/grades/services/grades';
import { CoreUserProfile } from '@features/user/services/user';
import {
CoreUserDelegateContext,
CoreUserDelegateService ,
CoreUserProfileHandlerType ,
CoreUserProfileHandler,
CoreUserProfileHandlerData,
} from '@features/user/services/user-delegate';
@ -38,7 +38,7 @@ export class CoreGradesUserHandlerService implements CoreUserProfileHandler {
name = 'CoreGrades'; // This name doesn't match any disabled feature, they'll be checked in isEnabledForContext.
priority = 500;
type = CoreUserDelegateService.TYPE_NEW_PAGE;
type = CoreUserProfileHandlerType.LIST_ITEM;
cacheEnabled = true;
/**

View File

@ -40,7 +40,7 @@
</ion-label>
</ion-item>
<ion-item button class="core-usermenu-handler ion-text-wrap" *ngIf="siteInfo" lines="full" (click)="openUserProfile($event)"
<ion-item button class="core-usermenu-profile ion-text-wrap" *ngIf="siteInfo" lines="full" (click)="openUserProfile($event)"
[detail]="true" [attr.aria-label]="'core.user.profile' | translate">
<core-user-avatar [site]="siteInfo" [userId]="siteInfo.userid" [linkProfile]="false" slot="start" />
<ion-label>
@ -48,12 +48,7 @@
</ion-label>
</ion-item>
<ion-item class="ion-text-center" *ngIf="(!handlers || !handlers.length) && !handlersLoaded">
<ion-label>
<ion-spinner [attr.aria-label]="'core.loading' | translate" />
</ion-label>
</ion-item>
@if (handlers.length + accountHandlers.length > 0) {
<ion-item button *ngFor="let handler of handlers" class="ion-text-wrap" (click)="handlerClicked($event, handler)"
[ngClass]="['core-user-menu-handler', handler.class || '']" [hidden]="handler.hidden"
[attr.aria-label]="handler.title | translate" [detail]="true">
@ -70,8 +65,35 @@
<ion-spinner slot="end" *ngIf="handler.showBadge && handler.loading" [attr.aria-label]="'core.loading' | translate" />
</ion-item>
<ion-item button *ngFor="let handler of accountHandlers; let first = first" class="ion-text-wrap"
(click)="handlerClicked($event, handler)"
[ngClass]="['core-user-account-menu-handler', handler.class || '', first ? 'core-user-menu-separator' : '']"
[hidden]="handler.hidden" [attr.aria-label]="handler.title | translate" [detail]="true">
<ion-icon *ngIf="handler.icon" [name]="handler.icon" slot="start" aria-hidden="true" />
<ion-label>
<p class="item-heading">{{ handler.title | translate }}</p>
</ion-label>
<ion-badge slot="end" *ngIf="handler.showBadge" [hidden]="handler.loading || !handler.badge" aria-hidden="true">
{{handler.badge}}
</ion-badge>
<span *ngIf="handler.showBadge && handler.badge && handler.badgeA11yText" class="sr-only">
{{ handler.badgeA11yText | translate: {$a : handler.badge } }}
</span>
<ion-spinner slot="end" *ngIf="handler.showBadge && handler.loading" [attr.aria-label]="'core.loading' | translate" />
</ion-item>
} @else {
<ion-item class="ion-text-center">
<ion-label>
<ion-spinner [attr.aria-label]="'core.loading' | translate" />
</ion-label>
</ion-item>
}
<ion-item button (click)="openPreferences($event)" [attr.aria-label]="'core.settings.preferences' | translate" [detail]="true"
class="core-user-menu-preferences">
class="core-user-menu-preferences core-user-menu-separator">
<ion-icon name="fas-wrench" slot="start" aria-hidden="true" />
<ion-label>
<p class="item-heading">{{ 'core.settings.preferences' | translate }}</p>

View File

@ -1,7 +1,7 @@
@use "theme/globals" as *;
:host {
.core-user-menu-preferences {
.core-user-menu-separator {
--inner-border-width: 0;
--border-width: 1px 0 0 0;
}

View File

@ -25,12 +25,13 @@ import { CoreUser, CoreUserProfile } from '@features/user/services/user';
import {
CoreUserProfileHandlerData,
CoreUserDelegate,
CoreUserDelegateService,
CoreUserProfileHandlerType,
CoreUserDelegateContext,
} from '@features/user/services/user-delegate';
import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { ModalController, Translate } from '@singletons';
import { Subscription } from 'rxjs';
@ -52,6 +53,7 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy {
siteUrl?: string;
displaySiteUrl = false;
handlers: CoreUserProfileHandlerData[] = [];
accountHandlers: CoreUserProfileHandlerData[] = [];
handlersLoaded = false;
user?: CoreUserProfile;
displaySwitchAccount = true;
@ -76,37 +78,48 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy {
this.loadSiteLogo(currentSite);
// Load the handlers.
if (this.siteInfo) {
try {
this.user = await CoreUser.getProfile(this.siteInfo.userid);
} catch {
this.user = {
id: this.siteInfo.userid,
fullname: this.siteInfo.fullname,
};
}
this.subscription = CoreUserDelegate.getProfileHandlersFor(this.user, CoreUserDelegateContext.USER_MENU)
.subscribe((handlers) => {
if (!handlers || !this.user) {
return;
}
const newHandlers = handlers
.filter((handler) => handler.type === CoreUserDelegateService.TYPE_NEW_PAGE)
.map((handler) => handler.data);
// Only update handlers if they have changed, to prevent a blink effect.
if (newHandlers.length !== this.handlers.length ||
JSON.stringify(newHandlers) !== JSON.stringify(this.handlers)) {
this.handlers = newHandlers;
}
this.handlersLoaded = CoreUserDelegate.areHandlersLoaded(this.user.id, CoreUserDelegateContext.USER_MENU);
});
if (!this.siteInfo) {
return;
}
// Load the handlers.
try {
this.user = await CoreUser.getProfile(this.siteInfo.userid);
} catch {
this.user = {
id: this.siteInfo.userid,
fullname: this.siteInfo.fullname,
};
}
this.subscription = CoreUserDelegate.getProfileHandlersFor(this.user, CoreUserDelegateContext.USER_MENU)
.subscribe((handlers) => {
if (!handlers.length || !this.user) {
return;
}
let newHandlers = handlers
.filter((handler) => handler.type === CoreUserProfileHandlerType.LIST_ITEM)
.map((handler) => handler.data);
// Only update handlers if they have changed, to prevent a blink effect.
if (newHandlers.length !== this.handlers.length ||
JSON.stringify(newHandlers) !== JSON.stringify(this.handlers)) {
this.handlers = newHandlers;
}
newHandlers = handlers
.filter((handler) => handler.type === CoreUserProfileHandlerType.LIST_ACCOUNT_ITEM)
.map((handler) => handler.data);
// Only update handlers if they have changed, to prevent a blink effect.
if (newHandlers.length !== this.handlers.length ||
JSON.stringify(newHandlers) !== JSON.stringify(this.handlers)) {
this.accountHandlers = newHandlers;
}
this.handlersLoaded = CoreUserDelegate.areHandlersLoaded(this.user.id, CoreUserDelegateContext.USER_MENU);
});
}
/**
@ -123,15 +136,9 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy {
return;
}
try {
const siteConfig = await currentSite.getPublicConfig();
this.siteLogo = currentSite.getLogoUrl(siteConfig);
} catch {
// Ignore errors.
} finally {
this.siteLogoLoaded = true;
}
const siteConfig = await CoreUtils.ignoreErrors(currentSite.getPublicConfig());
this.siteLogo = currentSite.getLogoUrl(siteConfig);
this.siteLogoLoaded = true;
}
/**

View File

@ -22,7 +22,7 @@
</ng-container>
<ng-container *ngIf="!isCardLayout">
<table>
<table class="core-table">
<thead>
<tr>
<th *ngFor="let header of state.report.data.headers">
@ -30,7 +30,7 @@
</th>
</tr>
</thead>
<tbody>
<tbody class="auto-striped">
<tr *ngFor="let row of state.report.data.rows">
<td *ngFor="let column of row.columns">
<core-format-text *ngIf="isString(column); else notText" [text]="column" [contextLevel]="source$ | async"

View File

@ -1,9 +1,4 @@
@use "theme/globals" as *;
:host {
--header-background: var(--white);
--border-color: var(--stroke);
.report-title {
ion-item {
width: 100%;
@ -11,32 +6,9 @@
}
table {
width: 98%;
margin: 1em auto;
border-collapse: collapse;
color: var(--ion-text-color);
overflow-x: auto;
display: block;
tbody {
display: table;
}
th {
background-color: var(--header-background);
}
tr {
border-bottom: 1px solid var(--border-color);
&:nth-child(even) {
background: var(--light);
}
}
th, td {
@include padding(8px, 8px, 8px, null);
text-align: start;
min-width: 200px;
}

View File

@ -13,7 +13,11 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreUserDelegateService, CoreUserProfileHandler, CoreUserProfileHandlerData } from '@features/user/services/user-delegate';
import {
CoreUserProfileHandlerType,
CoreUserProfileHandler,
CoreUserProfileHandlerData,
} from '@features/user/services/user-delegate';
import { CoreNavigator } from '@services/navigator';
import { makeSingleton } from '@singletons';
import { CoreReportBuilder } from '../reportbuilder';
@ -26,7 +30,7 @@ export class CoreReportBuilderHandlerService implements CoreUserProfileHandler {
static readonly PAGE_NAME = 'reportbuilder';
type = CoreUserDelegateService.TYPE_NEW_PAGE;
type = CoreUserProfileHandlerType.LIST_ITEM;
cacheEnabled = true;
name = 'CoreReportBuilderDelegate';
priority = 350;

View File

@ -22,7 +22,7 @@ import {
import { CoreUserProfile } from '@features/user/services/user';
import {
CoreUserDelegateContext,
CoreUserDelegateService,
CoreUserProfileHandlerType,
CoreUserProfileHandler,
CoreUserProfileHandlerData,
} from '@features/user/services/user-delegate';
@ -36,7 +36,7 @@ import { CoreSitePluginsBaseHandler } from './base-handler';
export class CoreSitePluginsUserProfileHandler extends CoreSitePluginsBaseHandler implements CoreUserProfileHandler {
priority: number;
type: string;
type: CoreUserProfileHandlerType;
protected updatingDefer?: CorePromisedValue<void>;
@ -51,9 +51,10 @@ export class CoreSitePluginsUserProfileHandler extends CoreSitePluginsBaseHandle
this.priority = handlerSchema.priority || 0;
// Only support TYPE_COMMUNICATION and TYPE_NEW_PAGE.
this.type = handlerSchema.type != CoreUserDelegateService.TYPE_COMMUNICATION ?
CoreUserDelegateService.TYPE_NEW_PAGE : CoreUserDelegateService.TYPE_COMMUNICATION;
// Only support LIST_ITEM and BUTTON.
this.type = !handlerSchema.type || handlerSchema.type === CoreUserProfileHandlerType.LIST_ACCOUNT_ITEM
? CoreUserProfileHandlerType.LIST_ITEM
: handlerSchema.type;
}
/**

View File

@ -33,6 +33,7 @@ import { CorePromisedValue } from '@classes/promised-value';
import { CorePlatform } from '@services/platform';
import { CoreEnrolAction, CoreEnrolInfoIcon } from '@features/enrol/services/enrol-delegate';
import { CoreSiteWSPreSets } from '@classes/sites/authenticated-site';
import { CoreUserProfileHandlerType } from '@features/user/services/user-delegate';
const ROOT_CACHE_KEY = 'CoreSitePlugins:';
@ -907,7 +908,7 @@ export type CoreSitePluginsUserHandlerData = CoreSitePluginsHandlerCommonData &
icon?: string;
class?: string;
};
type?: string;
type?: CoreUserProfileHandlerType;
priority?: number;
ptrenabled?: boolean;
};

View File

@ -33,11 +33,10 @@
</ion-label>
</ion-item>
<div class="core-user-communication-handlers"
*ngIf="(communicationHandlers && communicationHandlers.length) || isLoadingHandlers">
<ion-item *ngIf="communicationHandlers && communicationHandlers.length">
<div class="core-user-communication-handlers" *ngIf="(buttonHandlers && buttonHandlers.length) || isLoadingHandlers">
<ion-item *ngIf="buttonHandlers && buttonHandlers.length">
<ion-label>
<ion-button *ngFor="let handler of communicationHandlers" expand="block" size="default"
<ion-button *ngFor="let handler of buttonHandlers" expand="block" size="default"
[ngClass]="['core-user-profile-handler', handler.class || '']" (click)="handlerClicked($event, handler)"
[hidden]="handler.hidden" [attr.aria-label]="handler.title | translate" [disabled]="handler.spinner">
<ion-icon *ngIf="handler.icon" [name]="handler.icon" slot="start" aria-hidden="true" />
@ -61,7 +60,7 @@
<ion-spinner [attr.aria-label]="'core.loading' | translate" />
</ion-label>
</ion-item>
<ion-item button *ngFor="let handler of newPageHandlers" class="ion-text-wrap" (click)="handlerClicked($event, handler)"
<ion-item button *ngFor="let handler of listItemHandlers" class="ion-text-wrap" (click)="handlerClicked($event, handler)"
[ngClass]="['core-user-profile-handler', handler.class || '']" [hidden]="handler.hidden"
[attr.aria-label]="handler.title | translate" [detail]="true">
<ion-icon *ngIf="handler.icon" [name]="handler.icon" slot="start" aria-hidden="true" />
@ -76,17 +75,6 @@
</span>
<ion-spinner slot="end" *ngIf="handler.showBadge && handler.loading" [attr.aria-label]="'core.loading' | translate" />
</ion-item>
<ion-item *ngIf="actionHandlers && actionHandlers.length">
<ion-label>
<ion-button *ngFor="let handler of actionHandlers" expand="block" fill="outline" size="default"
[ngClass]="['core-user-profile-handler', handler.class || '']" (click)="handlerClicked($event, handler)"
[hidden]="handler.hidden" [attr.aria-label]="handler.title | translate" [disabled]="handler.spinner">
<ion-icon *ngIf="handler.icon" [name]="handler.icon" slot="start" aria-hidden="true" />
{{ handler.title | translate }}
<ion-spinner *ngIf="handler.spinner" slot="end" [attr.aria-label]="'core.loading' | translate" />
</ion-button>
</ion-label>
</ion-item>
</ion-list>
<core-empty-box *ngIf="!user && !isDeleted && isEnrolled" icon="far-user"
[message]=" 'core.user.detailsnotavailable' | translate" />

View File

@ -25,7 +25,7 @@ import { CoreUserHelper } from '@features/user/services/user-helper';
import {
CoreUserDelegate,
CoreUserDelegateContext,
CoreUserDelegateService,
CoreUserProfileHandlerType,
CoreUserProfileHandlerData,
} from '@features/user/services/user-delegate';
import { CoreUtils } from '@services/utils/utils';
@ -59,9 +59,8 @@ export class CoreUserProfilePage implements OnInit, OnDestroy {
isSuspended = false;
isEnrolled = true;
rolesFormatted?: string;
actionHandlers: CoreUserProfileHandlerData[] = [];
newPageHandlers: CoreUserProfileHandlerData[] = [];
communicationHandlers: CoreUserProfileHandlerData[] = [];
listItemHandlers: CoreUserProfileHandlerData[] = [];
buttonHandlers: CoreUserProfileHandlerData[] = [];
users?: CoreUserSwipeItemsManager;
@ -153,20 +152,19 @@ export class CoreUserProfilePage implements OnInit, OnDestroy {
const context = this.courseId ? CoreUserDelegateContext.COURSE : CoreUserDelegateContext.SITE;
this.subscription = CoreUserDelegate.getProfileHandlersFor(user, context, this.courseId).subscribe((handlers) => {
this.actionHandlers = [];
this.newPageHandlers = [];
this.communicationHandlers = [];
this.listItemHandlers = [];
this.buttonHandlers = [];
handlers.forEach((handler) => {
switch (handler.type) {
case CoreUserDelegateService.TYPE_COMMUNICATION:
this.communicationHandlers.push(handler.data);
case CoreUserProfileHandlerType.BUTTON:
this.buttonHandlers.push(handler.data);
break;
case CoreUserDelegateService.TYPE_ACTION:
this.actionHandlers.push(handler.data);
case CoreUserProfileHandlerType.LIST_ACCOUNT_ITEM:
// Discard this for now.
break;
case CoreUserDelegateService.TYPE_NEW_PAGE:
case CoreUserProfileHandlerType.LIST_ITEM:
default:
this.newPageHandlers.push(handler.data);
this.listItemHandlers.push(handler.data);
break;
}
});

View File

@ -14,7 +14,11 @@
import { Injectable } from '@angular/core';
import { CoreUserDelegateService, CoreUserProfileHandler, CoreUserProfileHandlerData } from '../user-delegate';
import {
CoreUserProfileHandlerType,
CoreUserProfileHandler,
CoreUserProfileHandlerData,
} from '../user-delegate';
import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreUserProfile } from '../user';
@ -28,7 +32,7 @@ export class CoreUserProfileMailHandlerService implements CoreUserProfileHandler
name = 'CoreUserProfileMail';
priority = 700;
type = CoreUserDelegateService.TYPE_COMMUNICATION;
type = CoreUserProfileHandlerType.BUTTON;
/**
* @inheritdoc

View File

@ -23,6 +23,12 @@ import { makeSingleton } from '@singletons';
import { CoreCourses, CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses';
import { CoreSites } from '@services/sites';
export enum CoreUserProfileHandlerType {
LIST_ITEM = 'listitem', // User profile handler type to be shown as a list item.
LIST_ACCOUNT_ITEM = 'account_listitem', // User profile handler type to be shown as a list item and it's related to an account.
BUTTON = 'button', // User profile handler type to be shown as a button.
}
declare module '@singletons/events' {
/**
@ -46,13 +52,11 @@ export interface CoreUserProfileHandler extends CoreDelegateHandler {
priority: number;
/**
* A type should be specified among these:
* - TYPE_COMMUNICATION: will be displayed under the user avatar. Should have icon. Spinner not used.
* - TYPE_NEW_PAGE: will be displayed as a list of items. Should have icon. Spinner not used.
* Default value if none is specified.
* - TYPE_ACTION: will be displayed as a button and should not redirect to any state. Spinner use is recommended.
* The type of Handler.
*
* @see CoreUserProfileHandlerType for more info.
*/
type: string;
type: CoreUserProfileHandlerType;
/**
* If isEnabledForUser Cache should be enabled.
@ -106,7 +110,7 @@ export interface CoreUserProfileHandlerData {
title: string;
/**
* Name of the icon to display. Mandatory for TYPE_COMMUNICATION.
* Name of the icon to display. Mandatory for CoreUserProfileHandlerType.BUTTON.
*/
icon?: string;
@ -116,32 +120,34 @@ export interface CoreUserProfileHandlerData {
class?: string;
/**
* If enabled, element will be hidden. Only for TYPE_NEW_PAGE and TYPE_ACTION.
* If enabled, element will be hidden. Only for CoreUserProfileHandlerType.LIST_ITEM.
*/
hidden?: boolean;
/**
* If enabled will show an spinner. Only for TYPE_ACTION.
* If enabled will show an spinner.
*
* @deprecated since 4.4. Not used anymore.
*/
spinner?: boolean;
/**
* If the handler has badge to show or not. Only for TYPE_NEW_PAGE.
* If the handler has badge to show or not. Only for CoreUserProfileHandlerType.LIST_ITEM.
*/
showBadge?: boolean;
/**
* Text to display on the badge. Only used if showBadge is true and only for TYPE_NEW_PAGE.
* Text to display on the badge. Only used if showBadge is true and only for CoreUserProfileHandlerType.LIST_ITEM.
*/
badge?: string;
/**
* Accessibility text to add on the badge. Only used if showBadge is true and only for TYPE_NEW_PAGE.
* Accessibility text to add on the badge. Only used if showBadge is true and only for CoreUserProfileHandlerType.LIST_ITEM.
*/
badgeA11yText?: string;
/**
* If true, the badge number is being loaded. Only used if showBadge is true and only for TYPE_NEW_PAGE.
* If true, the badge number is being loaded. Only used if showBadge is true and only for CoreUserProfileHandlerType.LIST_ITEM.
*/
loading?: boolean;
@ -195,14 +201,20 @@ export class CoreUserDelegateService extends CoreDelegate<CoreUserProfileHandler
/**
* User profile handler type for communication.
*
* @deprecated since 4.4. Use CoreUserProfileHandlerType.BUTTON instead.
*/
static readonly TYPE_COMMUNICATION = 'communication';
/**
* User profile handler type for new page.
*
* @deprecated since 4.4. Use CoreUserProfileHandlerType.LIST_ITEM instead.
*/
static readonly TYPE_NEW_PAGE = 'newpage';
/**
* User profile handler type for actions.
*
* @deprecated since 4.4. Use CoreUserProfileHandlerType.BUTTON instead.
*/
static readonly TYPE_ACTION = 'action';
@ -341,7 +353,7 @@ export class CoreUserDelegateService extends CoreDelegate<CoreUserProfileHandler
name: name,
data: handler.getDisplayData(user, context, courseId),
priority: handler.priority || 0,
type: handler.type || CoreUserDelegateService.TYPE_NEW_PAGE,
type: handler.type || CoreUserProfileHandlerType.LIST_ITEM,
});
}
} catch {
@ -485,6 +497,24 @@ export class CoreUserDelegateService extends CoreDelegate<CoreUserProfileHandler
return this.userHandlers[userId][contextKey];
}
/**
* @inheritdoc
*/
registerHandler(handler: CoreUserProfileHandler): boolean {
const type = handler.type as string;
// eslint-disable-next-line deprecation/deprecation
if (type == CoreUserDelegateService.TYPE_COMMUNICATION || type == CoreUserDelegateService.TYPE_ACTION) {
handler.type = CoreUserProfileHandlerType.BUTTON;
// eslint-disable-next-line deprecation/deprecation
} else if (type == CoreUserDelegateService.TYPE_NEW_PAGE) {
handler.type = CoreUserProfileHandlerType.LIST_ITEM;
}
return super.registerHandler(handler);
}
}
export const CoreUserDelegate = makeSingleton(CoreUserDelegateService);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -1711,9 +1711,11 @@ ion-input .native-input {
}
}
input {
ion-input,
ion-input input,
ion-textarea {
--placeholder-color: var(--ion-placeholder-color);
--placeholder-opacity: .85;
--placeholder-opacity: .65;
}
// Disable scroll on parent ion contents to enabled PTR on the ones inside the splitview. See split-view component for more info.
@ -1905,6 +1907,10 @@ swiper-container {
}
.core-flex-no-grow {
flex-grow: 0;
}
// Loader animation.
.core-loading {
position: relative;
@ -2015,3 +2021,83 @@ ion-item.item-label-stacked ion-datetime-button {
margin-bottom: 8px;
align-self: self-end;
}
// Table App styles
table.core-table {
border-collapse: collapse;
line-height: 20px;
width: 98%;
margin: 1em auto;
color: var(--text-color);
thead th {
vertical-align: bottom;
font-weight: bold;
font-size: 14px;
background-color: var(--core-table-header-background);
}
tbody th {
font-weight: normal;
}
th, td {
padding: 8px;
white-space: normal;
text-align: start;
}
tr {
border-bottom: 1px solid var(--core-table-border-color);
}
tbody td {
font-size: 16px;
}
.odd {
--cell-background: var(--core-table-odd-cell-background);
--cell-hover: var(--core-table-odd-cell-hover);
}
.even {
--cell-background: var(--core-table-even-cell-background);
--cell-hover: var(--core-table-even-cell-hover);
}
.odd, .even {
td, th, th[aria-current="page"] {
background-color: var(--cell-background);
&:hover {
background-color: var(--cell-hover);
}
}
}
tbody.auto-striped tr:nth-child(odd) {
background-color: var(--core-table-odd-cell-background);
&:hover {
background-color: var(--core-table-even-odd-hover);
}
}
tbody.auto-striped tr:nth-child(even) {
background-color: var(--core-table-even-cell-background);
&:hover {
background-color: var(--core-table-even-cell-hover);
}
}
.ion-no-border {
border: 0 !important;
}
.dimmed_text,
.hidden {
opacity: .7;
}
}

View File

@ -169,4 +169,10 @@ html.dark {
--addon-forum-border-color: var(--gray-500);
--addon-forum-highlight-color: var(--gray-800);
--core-table-header-background: var(--gray-900);
--core-table-odd-cell-background: var(--gray-800);
--core-table-odd-cell-hover: var(--gray-600);
--core-table-even-cell-background: var(--gray-900);
--core-table-even-cell-hover: var(--gray-700);
}

View File

@ -381,6 +381,13 @@ html {
--core-dd-question-radius: 10px;
--core-dd-question-border: var(--medium);
--core-table-header-background: var(--white);
--core-table-odd-cell-background: var(--light);
--core-table-odd-cell-hover: var(--gray-200);
--core-table-even-cell-background: var(--white);
--core-table-even-cell-hover: var(--light);
--core-table-border-color: var(--stroke);
--rotate-expandable: rotate(90deg);
&[dir=rtl] {
--rotate-expandable: rotate(-90deg);