Merge pull request #3533 from alfonso-salces/MOBILE-4077

Mobile 4077 - Support user custom reports
main
Noel De Martin 2023-02-01 13:18:25 +01:00 committed by GitHub
commit c1370250f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1362 additions and 0 deletions

View File

@ -1652,6 +1652,12 @@
"core.courses.totalcoursesearchresults": "local_moodlemobileapp",
"core.currentdevice": "local_moodlemobileapp",
"core.custom": "form",
"core.reportbuilder.modifiedby": "tool_reportbuilder",
"core.reportbuilder.reportstab": "tool_reportbuilder",
"core.reportbuilder.reportsource": "tool_reportbuilder",
"core.reportbuilder.timecreated": "tool_reportbuilder",
"core.reportbuilder.showcolumns": "local_moodlemobileapp",
"core.reportbuilder.hidecolumns": "local_moodlemobileapp",
"core.datastoredoffline": "local_moodlemobileapp",
"core.date": "moodle",
"core.datecreated": "repository",

View File

@ -43,6 +43,7 @@ import { CoreUserModule } from './user/user.module';
import { CoreUserToursModule } from './usertours/user-tours.module';
import { CoreViewerModule } from './viewer/viewer.module';
import { CoreXAPIModule } from './xapi/xapi.module';
import { CoreReportBuilderModule } from './reportbuilder/reportbuilder.module';
@NgModule({
imports: [
@ -74,6 +75,7 @@ import { CoreXAPIModule } from './xapi/xapi.module';
CoreUserToursModule,
CoreViewerModule,
CoreXAPIModule,
CoreReportBuilderModule,
// Import last to allow overrides.
CoreEmulatorModule,

View File

@ -0,0 +1,55 @@
// (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 { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source';
import { CoreReportBuilder, CoreReportBuilderReport, REPORTS_LIST_LIMIT } from '../services/reportbuilder';
/**
* Provides a list of reports.
*/
export class CoreReportBuilderReportsSource extends CoreRoutedItemsManagerSource<CoreReportBuilderReport> {
/**
* @inheritdoc
*/
getItemPath(report: CoreReportBuilderReport): string {
return report.id.toString();
}
/**
* @inheritdoc
*/
protected async loadPageItems(page: number): Promise<{ items: CoreReportBuilderReport[]; hasMoreItems: boolean }> {
const reports = await CoreReportBuilder.getReports(page, this.getPageLength());
return { items: reports, hasMoreItems: reports.length > 0 };
}
/**
* @inheritdoc
*/
protected setItems(reports: CoreReportBuilderReport[], hasMoreItems: boolean): void {
const sortedReports = reports.slice(0);
reports.sort((a, b) => a.timecreated < b.timecreated ? 1 : -1);
super.setItems(sortedReports, hasMoreItems);
}
/**
* @inheritdoc
*/
protected getPageLength(): number {
return REPORTS_LIST_LIMIT;
}
}

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 { CoreSharedModule } from '@/core/shared.module';
import { CoreReportBuilderReportColumnComponent } from './report-column/report-column';
import { CoreReportBuilderReportDetailComponent } from './report-detail/report-detail';
import { CoreReportBuilderReportSummaryComponent } from './report-summary/report-summary';
@NgModule({
imports: [
CoreSharedModule,
],
declarations: [
CoreReportBuilderReportDetailComponent,
CoreReportBuilderReportColumnComponent,
CoreReportBuilderReportSummaryComponent,
],
exports: [
CoreReportBuilderReportDetailComponent,
CoreReportBuilderReportColumnComponent,
CoreReportBuilderReportSummaryComponent,
],
})
export class CoreReportBuilderComponentsModule {}

View File

@ -0,0 +1,11 @@
<ion-item class="ion-text-wrap" lines="inset" [detail]="false" [button]="isExpandable" [attr.aria-expanded]="isExpanded"
[attr.aria-controls]="'core-report-builder-column-' + rowIndex"
[attr.aria-label]="(isExpanded ? 'core.hidecolumns' : 'core.showcolumns') | translate" (click)="toggleRow()">
<ion-label>
<h3 *ngIf="columnIndex !== 0 || (columnIndex === 0 && showFirstTitle)"> {{ header }} </h3>
<core-format-text [text]="column" contextLevel="site" [contextInstanceId]="contextId"></core-format-text>
</ion-label>
<ion-icon [class.expandable-status-icon-expanded]="!isExpanded" slot="end" aria-hidden="true" name="fas-chevron-up"
class="expandable-status-icon" *ngIf="isExpandable" flip-rtl>
</ion-icon>
</ion-item>

View File

@ -0,0 +1,11 @@
@import "~theme/globals";
:host {
--rotate-expandable: rotate(180deg);
.expandable-status-icon {
font-size: var(--text-size);
@include margin-horizontal(0, 2px);
@include core-transition(transform, 200ms);
}
}

View File

@ -0,0 +1,41 @@
// (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, EventEmitter, Input, Output } from '@angular/core';
@Component({
selector: 'core-report-builder-report-column',
templateUrl: './report-column.html',
styleUrls: ['./report-column.scss'],
})
export class CoreReportBuilderReportColumnComponent {
@Input() isExpanded = false;
@Input() isExpandable = false;
@Input() showFirstTitle = false;
@Input() columnIndex!: number;
@Input() rowIndex!: number;
@Input() column!: string;
@Input() contextId!: number;
@Input() header!: string;
@Output() onToggleRow: EventEmitter<number> = new EventEmitter();
/**
* Emits row click
*/
toggleRow(): void {
this.onToggleRow.emit(this.rowIndex);
}
}

View File

@ -0,0 +1,60 @@
<ng-container *ngIf="state$ | async as state">
<core-loading [hideUntil]="state.loaded">
<ng-container *ngIf="state.report?.data?.rows && state.report?.data?.headers && state.report?.details; else empty">
<ion-refresher slot="fixed" [disabled]="!state.loaded" (ionRefresh)="refreshReport($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<ng-container *ngIf="isCardLayout">
<ion-card *ngFor="let row of state.report.data.rows; let rowIndex = index">
<ion-list class="ion-text-wrap">
<core-report-builder-report-column *ngFor="let column of row.columns | slice:0:row.isExpanded ?
row.columns.length : state.cardVisibleColumns; let columnIndex = index" [columnIndex]="columnIndex"
[rowIndex]="rowIndex" [isExpandable]="columnIndex === 0 && row.columns.length > state.cardVisibleColumns"
[isExpanded]="row.isExpanded" [showFirstTitle]="state.cardviewShowFirstTitle"
[contextId]="state.report.details.contextid" [header]="state.report.data.headers[columnIndex]" [column]="column"
(onToggleRow)="toggleRow(rowIndex)">
</core-report-builder-report-column>
</ion-list>
</ion-card>
</ng-container>
<ng-container *ngIf="!isCardLayout">
<table>
<thead>
<tr>
<th *ngFor="let header of state.report.data.headers">
{{ header }}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of state.report.data.rows">
<td *ngFor="let column of row.columns">
<core-format-text [text]="column" [contextLevel]="'site'"
[contextInstanceId]="state.report.details.contextid">
</core-format-text>
</td>
</tr>
</tbody>
</table>
</ng-container>
</ng-container>
<ng-template #empty>
<core-empty-box *ngIf="!state.report?.data?.rows || !state.report?.data?.headers || !state.report?.details" icon="fa-list-alt"
[message]="'core.course.nocontentavailable' | translate"></core-empty-box>
</ng-template>
<core-infinite-loading *ngIf="!isBlock && state.report?.data?.rows && state.report?.data?.headers && state.report?.details"
[enabled]="state.canLoadMoreRows" (action)="fetchMoreInfo($event)" [error]="state.errorLoadingRows">
</core-infinite-loading>
</core-loading>
</ng-container>

View File

@ -0,0 +1,44 @@
@import "~theme/globals";
:host {
--header-background: var(--white);
--border-color: var(--stroke);
.report-title {
ion-item {
width: 100%;
}
}
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

@ -0,0 +1,201 @@
// (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 { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { CoreError } from '@classes/errors/error';
import {
CoreReportBuilder,
CoreReportBuilderReportDetail,
CoreReportBuilderRetrieveReportMapped,
REPORT_ROWS_LIMIT,
} from '@features/reportbuilder/services/reportbuilder';
import { IonRefresher } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator';
import { CoreScreen } from '@services/screen';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { BehaviorSubject } from 'rxjs';
@Component({
selector: 'core-report-builder-report-detail',
templateUrl: './report-detail.html',
styleUrls: ['./report-detail.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CoreReportBuilderReportDetailComponent implements OnInit {
@Input() reportId!: string;
@Input() isBlock = true;
@Input() perPage?: number;
@Input() layout: 'card' | 'table' | 'adaptative' = 'adaptative';
@Output() onReportLoaded = new EventEmitter<CoreReportBuilderReportDetail>();
get isCardLayout(): boolean {
return this.layout === 'card' || (CoreScreen.isMobile && this.layout === 'adaptative');
}
state$: Readonly<BehaviorSubject<CoreReportBuilderReportDetailState>> = new BehaviorSubject({
report: null,
loaded: false,
canLoadMoreRows: true,
errorLoadingRows: false,
cardviewShowFirstTitle: false,
cardVisibleColumns: 1,
page: 0,
});
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
await this.getReport();
this.updateState({ loaded: true });
}
/**
* Get report data.
*/
async getReport(): Promise<void> {
if (!this.reportId) {
CoreDomUtils.showErrorModal(new CoreError('No report found'));
CoreNavigator.back();
return;
}
const { page } = this.state$.getValue();
const report = await CoreReportBuilder.loadReport(parseInt(this.reportId), page,this.perPage ?? REPORT_ROWS_LIMIT);
if (!report) {
CoreDomUtils.showErrorModal(new CoreError('No report found'));
CoreNavigator.back();
return;
}
await CoreReportBuilder.viewReport(this.reportId);
this.updateState({
report,
cardVisibleColumns: report.details.settingsdata.cardviewVisibleColumns,
cardviewShowFirstTitle: report.details.settingsdata.cardviewShowFirstTitle,
});
this.onReportLoaded.emit(report.details);
}
updateState(state: Partial<CoreReportBuilderReportDetailState>): void {
const previousState = this.state$.getValue();
this.state$.next({ ...previousState, ...state });
}
/**
* Update report data.
*
* @param ionRefresher ionic refresher.
*/
async refreshReport(ionRefresher?: IonRefresher): Promise<void> {
await CoreUtils.ignoreErrors(CoreReportBuilder.invalidateReport());
this.updateState({ page: 0, canLoadMoreRows: false });
await CoreUtils.ignoreErrors(this.getReport());
await ionRefresher?.complete();
this.updateState({ canLoadMoreRows: true });
}
/**
* Increment page of report rows.
*/
protected incrementPage(): void {
const { page } = this.state$.getValue();
this.updateState({ page: page + 1 });
}
/**
* Load a new batch of pages.
*
* @param complete Completion callback.
*/
async fetchMoreInfo(complete: () => void): Promise<void> {
const { canLoadMoreRows, report } = this.state$.getValue();
if (!canLoadMoreRows) {
complete();
return;
}
try {
this.incrementPage();
const { page: currentPage } = this.state$.getValue();
const newReport = await CoreReportBuilder.loadReport(parseInt(this.reportId), currentPage, REPORT_ROWS_LIMIT);
if (!report || !newReport || newReport.data.rows.length === 0) {
this.updateState({ canLoadMoreRows: false });
complete();
return;
}
this.updateState({
report: {
...report,
data: {
...report.data,
rows: [
...report.data.rows,
...newReport.data.rows,
],
},
},
});
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error loading more reports');
this.updateState({ canLoadMoreRows: false });
this.updateState({ errorLoadingRows: true });
}
complete();
}
/**
* Expand or close card.
*
* @param rowIndex card to expand or close.
*/
toggleRow(rowIndex: number): void {
const { report } = this.state$.getValue();
if (!report?.data?.rows[rowIndex]) {
return;
}
report.data.rows[rowIndex].isExpanded = !report.data.rows[rowIndex].isExpanded;
this.updateState({ report });
}
}
export type CoreReportBuilderReportDetailState = {
report: CoreReportBuilderRetrieveReportMapped | null;
loaded: boolean;
canLoadMoreRows: boolean;
errorLoadingRows: boolean;
cardviewShowFirstTitle: boolean;
cardVisibleColumns: number;
page: number;
};

View File

@ -0,0 +1,39 @@
<ion-header class="no-title">
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fas-times" aria-hidden="true"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<div class="list-item-limited-width">
<ion-item class="ion-text-wrap course-name">
<ion-label>
<h1>
<core-format-text [text]="reportDetail.name" contextLevel="report" [contextInstanceId]="reportDetail.id">
</core-format-text>
</h1>
</ion-label>
<ion-button fill="clear" [href]="reportUrl" core-link [showBrowserWarning]="false"
[attr.aria-label]="'core.openinbrowser' | translate" slot="end">
<ion-icon name="fas-external-link-alt" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-item>
<ion-item class="ion-text-wrap" [detail]="false" *ngFor="let item of reportDetailToDisplay">
<ion-label>
<p>{{ item.title | translate }}</p>
<core-format-text [text]="item.text" contextLevel="report" [contextInstanceId]="reportDetail.id">
</core-format-text>
</ion-label>
</ion-item>
</div>
</ion-content>

View File

@ -0,0 +1,60 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { CoreReportBuilderReportDetail } from '@features/reportbuilder/services/reportbuilder';
import { CoreFormatDatePipe } from '@pipes/format-date';
import { CoreSites } from '@services/sites';
import { ModalController } from '@singletons';
@Component({
selector: 'core-report-builder-report-summary',
templateUrl: './report-summary.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CoreReportBuilderReportSummaryComponent implements OnInit {
@Input() reportDetail!: CoreReportBuilderReportDetail;
reportUrl!: string;
reportDetailToDisplay!: { title: string; text: string }[];
ngOnInit(): void {
const formatDate = new CoreFormatDatePipe();
const site = CoreSites.getRequiredCurrentSite();
this.reportUrl = `${site.getURL()}/reportbuilder/view.php?id=${this.reportDetail.id}`;
this.reportDetailToDisplay = [
{
title: 'core.reportbuilder.reportsource',
text: this.reportDetail.sourcename,
},
{
title: 'core.reportbuilder.timecreated',
text: formatDate.transform(this.reportDetail.timecreated * 1000),
},
{
title: 'addon.mod_data.timemodified',
text: formatDate.transform(this.reportDetail.timemodified * 1000),
},
{
title: 'core.reportbuilder.modifiedby',
text: this.reportDetail.modifiedby.fullname,
},
];
}
closeModal(): void {
ModalController.dismiss();
}
}

View File

@ -0,0 +1,8 @@
{
"modifiedby": "Modified by",
"reportstab": "Reports",
"reportsource": "Report source",
"timecreated": "Time created",
"showcolumns": "Show columns",
"hidecolumns": "Hide columns"
}

View File

@ -0,0 +1,35 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<h1>{{ 'core.reportbuilder.reportstab' | translate }}</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content *ngIf="state$ | async as state">
<ion-refresher slot="fixed" [disabled]="!state.loaded" (ionRefresh)="refreshReports($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="state.loaded">
<ion-list *ngIf="reports.items?.length; else empty">
<ion-item [attr.aria-current]="reports.getItemAriaCurrent(report)" [detail]="true" class="ion-text-wrap" [button]="true"
*ngFor="let report of reports.items" (click)="reports.select(report)">
<ion-label>
<h3>{{ report.name }}</h3>
<p>{{ report.sourcename }}</p>
</ion-label>
</ion-item>
</ion-list>
<ng-template #empty>
<core-empty-box *ngIf="!reports.items?.length" icon="fa-list-alt" [message]="'core.course.nocontentavailable' | translate">
</core-empty-box>
</ng-template>
<core-infinite-loading *ngIf="reports.items?.length" [enabled]="reports.loaded && !reports.completed"
(action)="fetchMoreReports($event)" [error]="state.loadMoreError">
</core-infinite-loading>
</core-loading>
</ion-content>

View File

@ -0,0 +1,128 @@
// (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 { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy } from '@angular/core';
import { CoreListItemsManager } from '@classes/items-management/list-items-manager';
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker';
import { CoreReportBuilderReportsSource } from '@features/reportbuilder/classes/reports-source';
import { CoreReportBuilder, CoreReportBuilderReport, REPORTS_LIST_LIMIT } from '@features/reportbuilder/services/reportbuilder';
import { IonRefresher } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { BehaviorSubject } from 'rxjs';
@Component({
selector: 'core-report-builder-list',
templateUrl: './list.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CoreReportBuilderListPage implements AfterViewInit, OnDestroy {
reports!: CoreListItemsManager<CoreReportBuilderReport, CoreReportBuilderReportsSource>;
state$: Readonly<BehaviorSubject<CoreReportBuilderListState>> = new BehaviorSubject({
page: 1,
perpage: REPORTS_LIST_LIMIT,
loaded: false,
loadMoreError: false,
});
constructor() {
try {
const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(CoreReportBuilderReportsSource, []);
this.reports = new CoreListItemsManager(source, CoreReportBuilderListPage);
} catch (error) {
CoreDomUtils.showErrorModal(error);
CoreNavigator.back();
}
}
/**
* @inheritdoc
*/
async ngAfterViewInit(): Promise<void> {
try {
await this.fetchReports(true);
this.updateState({ loaded: true });
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error loading reports');
this.reports.reset();
}
}
/**
* Update reports list or loads it.
*
* @param reload is reoading or not.
*/
async fetchReports(reload: boolean): Promise<void> {
reload ? await this.reports.reload() : await this.reports.load();
this.updateState({ loadMoreError: false });
}
/**
* Properties of the state to update.
*
* @param state Object to update.
*/
updateState(state: Partial<CoreReportBuilderListState>): void {
const previousState = this.state$.getValue();
this.state$.next({ ...previousState, ...state });
}
/**
* Load a new batch of Reports.
*
* @param complete Completion callback.
*/
async fetchMoreReports(complete: () => void): Promise<void> {
try {
await this.fetchReports(false);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error loading more reports');
this.updateState({ loadMoreError: true });
}
complete();
}
/**
* Refresh reports list.
*
* @param ionRefresher ionRefresher.
*/
async refreshReports(ionRefresher?: IonRefresher): Promise<void> {
await CoreUtils.ignoreErrors(CoreReportBuilder.invalidateReportsList());
await CoreUtils.ignoreErrors(this.fetchReports(true));
await ionRefresher?.complete();
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.reports.destroy();
}
}
type CoreReportBuilderListState = {
page: number;
perpage: number;
loaded: boolean;
loadMoreError: boolean;
};

View File

@ -0,0 +1,21 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="openInfo()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fas-info-circle" aria-hidden="true"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title *ngIf="reportDetail">
<h1> {{ reportDetail.name }} </h1>
<p class="subheading"> {{ reportDetail.sourcename }} </p>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<core-report-builder-report-detail [isBlock]="false" [reportId]="reportId" (onReportLoaded)="loadReportDetail($event)">
</core-report-builder-report-detail>
</ion-content>

View File

@ -0,0 +1,52 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit } from '@angular/core';
import { CoreReportBuilderReportSummaryComponent } from '@features/reportbuilder/components/report-summary/report-summary';
import { CoreReportBuilderReportDetail } from '@features/reportbuilder/services/reportbuilder';
import { CoreNavigator } from '@services/navigator';
import { CoreDomUtils } from '@services/utils/dom';
@Component({
selector: 'core-report-builder-report',
templateUrl: './report.html',
})
export class CoreReportBuilderReportPage implements OnInit {
reportId!: string;
reportDetail?: CoreReportBuilderReportDetail;
/**
* @inheritdoc
*/
ngOnInit(): void {
this.reportId = CoreNavigator.getRequiredRouteParam('id');
}
/**
* Save the report detail
*
* @param reportDetail it contents the detail of the report.
*/
loadReportDetail(reportDetail: CoreReportBuilderReportDetail): void {
this.reportDetail = reportDetail;
}
openInfo(): void {
CoreDomUtils.openSideModal<void>({
component: CoreReportBuilderReportSummaryComponent,
componentProps: { reportDetail: this.reportDetail },
});
}
}

View File

@ -0,0 +1,44 @@
// (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 { CoreSharedModule } from '@/core/shared.module';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CoreReportBuilderComponentsModule } from './components/components.module';
import { CoreReportBuilderListPage } from './pages/list/list';
import { CoreReportBuilderReportPage } from './pages/report/report';
const routes: Routes = [
{
path: '',
component: CoreReportBuilderListPage,
},
{
path: ':id',
component: CoreReportBuilderReportPage,
},
];
@NgModule({
imports: [
CoreSharedModule,
CoreReportBuilderComponentsModule,
RouterModule.forChild(routes),
],
declarations: [
CoreReportBuilderListPage,
CoreReportBuilderReportPage,
],
})
export class CoreReportBuilderLazyModule {}

View File

@ -0,0 +1,39 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CoreUserDelegate } from '@features/user/services/user-delegate';
import { CoreReportBuilderHandler, CoreReportBuilderHandlerService } from './services/handlers/reportbuilder';
const routes: Routes = [
{
path: CoreReportBuilderHandlerService.PAGE_NAME,
loadChildren: () => import('./reportbuilder-lazy.module').then(m => m.CoreReportBuilderLazyModule),
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
useValue: () => {
CoreUserDelegate.registerHandler(CoreReportBuilderHandler.instance);
},
},
],
})
export class CoreReportBuilderModule {}

View File

@ -0,0 +1,59 @@
// (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 { CoreUserDelegateService, CoreUserProfileHandler, CoreUserProfileHandlerData } from '@features/user/services/user-delegate';
import { CoreNavigator } from '@services/navigator';
import { makeSingleton } from '@singletons';
import { CoreReportBuilder } from '../reportbuilder';
/**
* Handler to visualize custom reports.
*/
@Injectable({ providedIn: 'root' })
export class CoreReportBuilderHandlerService implements CoreUserProfileHandler {
static readonly PAGE_NAME = 'reportbuilder';
type = CoreUserDelegateService.TYPE_NEW_PAGE;
cacheEnabled = true;
name = 'CoreReportBuilderDelegate';
priority = 350;
/**
* @inheritdoc
*/
async isEnabled(): Promise<boolean> {
return await CoreReportBuilder.isEnabled();
}
/**
* @inheritdoc
*/
getDisplayData(): CoreUserProfileHandlerData {
return {
class: 'core-report-builder',
icon: 'fa-list-alt',
title: 'core.reportbuilder.reportstab',
action: async (event): Promise<void> => {
event.preventDefault();
event.stopPropagation();
await CoreNavigator.navigate(`/${CoreReportBuilderHandlerService.PAGE_NAME}`);
},
};
}
}
export const CoreReportBuilderHandler = makeSingleton(CoreReportBuilderHandlerService);

View File

@ -0,0 +1,265 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// (C) Copyright 2015 Moodle Pty Ltd.
//
import { Injectable } from '@angular/core';
import { CoreError } from '@classes/errors/error';
import { CoreSiteWSPreSets } from '@classes/site';
import { CoreSites } from '@services/sites';
import { CoreWSExternalWarning } from '@services/ws';
import { makeSingleton } from '@singletons';
const ROOT_CACHE_KEY = 'mmaReportBuilder:';
export const REPORTS_LIST_LIMIT = 20;
export const REPORT_ROWS_LIMIT = 20;
@Injectable({ providedIn: 'root' })
export class CoreReportBuilderService {
/**
* Obtain the reports list.
*
* @param page Current page.
* @param perpage Reports obtained per page.
* @returns Reports list.
*/
async getReports(page?: number, perpage?: number): Promise<CoreReportBuilderReport[]> {
const site = CoreSites.getRequiredCurrentSite();
const preSets: CoreSiteWSPreSets = { cacheKey: this.getReportBuilderCacheKey() };
const response = await site.read<CoreReportBuilderListReportsWSResponse>(
'core_reportbuilder_list_reports',
{ page, perpage },
preSets,
);
return response.reports;
}
/**
* Get the detail of a report.
*
* @param reportid Report id
* @param page Current page.
* @param perpage Rows obtained per page.
* @returns Detail of the report.
*/
async loadReport(reportid: number, page?: number, perpage?: number): Promise<CoreReportBuilderRetrieveReportMapped> {
const site = CoreSites.getRequiredCurrentSite();
const preSets: CoreSiteWSPreSets = { cacheKey: this.getReportBuilderReportCacheKey() };
const report = await site.read<CoreReportBuilderRetrieveReportWSResponse>(
'core_reportbuilder_retrieve_report',
{ reportid, page, perpage },
preSets,
);
if (!report) {
throw new CoreError('An error ocurred.');
}
const settingsData: {
// eslint-disable-next-line @typescript-eslint/naming-convention
cardview_showfirsttitle: number;
// eslint-disable-next-line @typescript-eslint/naming-convention
cardview_visiblecolumns: number;
} = report.details.settingsdata ? JSON.parse(report.details.settingsdata) : {};
const mappedSettingsData: CoreReportBuilderReportDetailSettingsData = {
cardviewShowFirstTitle: settingsData.cardview_showfirsttitle === 1,
cardviewVisibleColumns: settingsData.cardview_visiblecolumns ?? 1,
};
return {
...report,
details: {
...report.details,
settingsdata: mappedSettingsData,
},
data: {
...report.data,
rows: [...report.data.rows.map(row => ({ columns: row.columns, isExpanded: row.isExpanded ?? false }))],
},
};
}
/**
* View a report.
*
* @param reportid Report viewed.
* @returns Response of the WS.
*/
async viewReport(reportid: string): Promise<void> {
const site = CoreSites.getRequiredCurrentSite();
await site.write<CoreReportBuilderViewReportWSResponse>('core_reportbuilder_view_report', { reportid });
}
/**
* Check if the feature is enabled or disabled.
*
* @returns Feature enabled or disabled.
*/
async isEnabled(): Promise<boolean> {
const site = CoreSites.getRequiredCurrentSite();
const hasTheVersionRequired = site.isVersionGreaterEqualThan('4.1');
const hasAdvancedFeatureEnabled = site.canUseAdvancedFeature('enablecustomreports');
const isFeatureDisabled = site.isFeatureDisabled('CoreReportBuilderDelegate');
return hasTheVersionRequired && hasAdvancedFeatureEnabled && !isFeatureDisabled;
}
/**
* Invalidates reports list WS calls.
*
* @returns Promise resolved when the list is invalidated.
*/
async invalidateReportsList(): Promise<void> {
const site = CoreSites.getRequiredCurrentSite();
await site.invalidateWsCacheForKey(this.getReportBuilderCacheKey());
}
/**
* Invalidates report WS calls.
*
* @returns Promise resolved when report is invalidated.
*/
async invalidateReport(): Promise<void> {
const site = CoreSites.getCurrentSite();
if (!site) {
return;
}
await site.invalidateWsCacheForKey(this.getReportBuilderReportCacheKey());
}
/**
* Get cache key for report builder list WS calls.
*
* @returns Cache key.
*/
protected getReportBuilderCacheKey(): string {
return ROOT_CACHE_KEY + 'list';
}
/**
* Get cache key for report builder report WS calls.
*
* @returns Cache key.
*/
protected getReportBuilderReportCacheKey(): string {
return ROOT_CACHE_KEY + 'report';
}
}
export const CoreReportBuilder = makeSingleton(CoreReportBuilderService);
type CoreReportBuilderPagination = {
page?: number;
perpage?: number;
};
export type CoreReportBuilderRetrieveReportWSParams = CoreReportBuilderPagination & {
reportid: number; // Report ID.
};
/**
* Data returned by core_reportbuilder_list_reports WS.
*/
export type CoreReportBuilderListReportsWSResponse = {
reports: CoreReportBuilderReportWSResponse[];
warnings?: CoreWSExternalWarning[];
};
export type CoreReportBuilderReportWSResponse = {
name: string; // Name.
source: string; // Source.
type: number; // Type.
uniquerows: boolean; // Uniquerows.
conditiondata: string; // Conditiondata.
settingsdata: string | null; // Settingsdata.
contextid: number; // Contextid.
component: string; // Component.
area: string; // Area.
itemid: number; // Itemid.
usercreated: number; // Usercreated.
id: number; // Id.
timecreated: number; // Timecreated.
timemodified: number; // Timemodified.
usermodified: number; // Usermodified.
sourcename: string; // Sourcename.
modifiedby: {
id: number; // Id.
email: string; // Email.
idnumber: string; // Idnumber.
phone1: string; // Phone1.
phone2: string; // Phone2.
department: string; // Department.
institution: string; // Institution.
fullname: string; // Fullname.
identity: string; // Identity.
profileurl: string; // Profileurl.
profileimageurl: string; // Profileimageurl.
profileimageurlsmall: string; // Profileimageurlsmall.
};
};
/**
* Data returned by core_reportbuilder_retrieve_report WS.
*/
export type CoreReportBuilderRetrieveReportWSResponse = {
details: CoreReportBuilderReportWSResponse;
data: CoreReportBuilderReportDataWSResponse;
warnings?: CoreWSExternalWarning[];
};
export interface CoreReportBuilderRetrieveReportMapped extends Omit<CoreReportBuilderRetrieveReportWSResponse, 'details'> {
details: CoreReportBuilderReportDetail;
}
export type CoreReportBuilderReportDataWSResponse = {
headers: string[]; // Headers.
rows: { // Rows.
columns: string[]; // Columns.
isExpanded: boolean;
}[];
totalrowcount: number; // Totalrowcount.
};
/**
* Params of core_reportbuilder_view_report WS.
*/
export type CoreReportBuilderViewReportWSParams = {
reportid: number; // Report ID.
};
/**
* Data returned by core_reportbuilder_view_report WS.
*/
export type CoreReportBuilderViewReportWSResponse = {
status: boolean; // Success.
warnings?: CoreWSExternalWarning[];
};
export interface CoreReportBuilderReportDetail extends Omit<CoreReportBuilderReportWSResponse, 'settingsdata'> {
settingsdata: CoreReportBuilderReportDetailSettingsData;
}
export type CoreReportBuilderReportDetailSettingsData = {
cardviewShowFirstTitle: boolean;
cardviewVisibleColumns: number;
};
export interface CoreReportBuilderReport extends CoreReportBuilderReportWSResponse {};

View File

@ -0,0 +1,145 @@
@app @javascript @core_reportbuilder
Feature: Report builder
Background:
Given the following "core_reportbuilder > Reports" exist:
| name | source | default |
| My report 01 | core_user\reportbuilder\datasource\users | 1 |
| My report 02 | core_user\reportbuilder\datasource\users | 2 |
| My report 03 | core_user\reportbuilder\datasource\users | 3 |
| My report 04 | core_user\reportbuilder\datasource\users | 4 |
| My report 05 | core_user\reportbuilder\datasource\users | 5 |
| My report 06 | core_user\reportbuilder\datasource\users | 6 |
| My report 07 | core_user\reportbuilder\datasource\users | 7 |
| My report 08 | core_user\reportbuilder\datasource\users | 8 |
| My report 09 | core_user\reportbuilder\datasource\users | 9 |
| My report 10 | core_user\reportbuilder\datasource\users | 10 |
| My report 11 | core_user\reportbuilder\datasource\users | 11 |
| My report 12 | core_user\reportbuilder\datasource\users | 12 |
| My report 13 | core_user\reportbuilder\datasource\users | 13 |
| My report 14 | core_user\reportbuilder\datasource\users | 14 |
| My report 15 | core_user\reportbuilder\datasource\users | 15 |
| My report 16 | core_user\reportbuilder\datasource\users | 16 |
| My report 17 | core_user\reportbuilder\datasource\users | 17 |
| My report 18 | core_user\reportbuilder\datasource\users | 18 |
| My report 19 | core_user\reportbuilder\datasource\users | 19 |
| My report 20 | core_user\reportbuilder\datasource\users | 20 |
| My report 21 | core_user\reportbuilder\datasource\users | 21 |
| My report 22 | core_user\reportbuilder\datasource\users | 22 |
| My report 23 | core_user\reportbuilder\datasource\users | 23 |
| My report 24 | core_user\reportbuilder\datasource\users | 24 |
| My report 25 | core_user\reportbuilder\datasource\users | 25 |
| My report 26 | core_user\reportbuilder\datasource\users | 26 |
| My report 27 | core_user\reportbuilder\datasource\users | 27 |
| My report 28 | core_user\reportbuilder\datasource\users | 28 |
| My report 29 | core_user\reportbuilder\datasource\users | 29 |
| My report 30 | core_user\reportbuilder\datasource\users | 30 |
| My report 31 | core_user\reportbuilder\datasource\users | 31 |
| My report 32 | core_user\reportbuilder\datasource\users | 32 |
| My report 33 | core_user\reportbuilder\datasource\users | 33 |
| My report 34 | core_user\reportbuilder\datasource\users | 34 |
| My report 35 | core_user\reportbuilder\datasource\users | 35 |
And the following "core_reportbuilder > Columns" exist:
| report | uniqueidentifier |
| My report 01 | user:fullname |
| My report 02 | user:fullname |
| My report 03 | user:fullname |
| My report 04 | user:fullname |
| My report 05 | user:fullname |
| My report 06 | user:fullname |
| My report 07 | user:fullname |
| My report 08 | user:fullname |
| My report 09 | user:fullname |
| My report 10 | user:fullname |
| My report 11 | user:fullname |
| My report 12 | user:fullname |
| My report 13 | user:fullname |
| My report 14 | user:fullname |
| My report 15 | user:fullname |
| My report 16 | user:fullname |
| My report 17 | user:fullname |
| My report 18 | user:fullname |
| My report 19 | user:fullname |
| My report 20 | user:fullname |
| My report 21 | user:fullname |
| My report 22 | user:fullname |
| My report 23 | user:fullname |
| My report 24 | user:fullname |
| My report 25 | user:fullname |
| My report 26 | user:fullname |
| My report 27 | user:fullname |
| My report 28 | user:fullname |
| My report 29 | user:fullname |
| My report 30 | user:fullname |
| My report 31 | user:fullname |
| My report 32 | user:fullname |
| My report 33 | user:fullname |
| My report 34 | user:fullname |
| My report 35 | user:fullname |
And the following "core_reportbuilder > Audiences" exist:
| report | configdata | classname |
| My report 01 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 02 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 03 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 04 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 05 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 06 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 07 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 08 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 09 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 10 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 11 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 12 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 13 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 14 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 15 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 16 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 17 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 18 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 19 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 20 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 21 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 22 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 23 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 24 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 25 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 26 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 27 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 28 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 29 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 30 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 31 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 32 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 33 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 34 | | core_reportbuilder\reportbuilder\audience\allusers |
| My report 35 | | core_reportbuilder\reportbuilder\audience\allusers |
And the following "users" exist:
| username | firstname | lastname | email | city |
| student1 | Lionel | Smith | lionel@example.com | Bilbao |
Scenario: Open report in mobile
Given I enter the app
And I log in as "student1"
And I press the user menu button in the app
When I press "Reports" in the app
# Find report in the screen
Then I should find "My report 03" in the app
And I press "My report 03" in the app
And I should find "My report 03" in the app
And I should find "Lionel Smith" in the app
But I should not find "My report 02" in the app
Scenario: Open report in tablet
Given I enter the app
And I change viewport size to "1200x640"
And I log in as "student1"
And I press the user menu button in the app
When I press "Reports" in the app
# Find report in the screen
Then I should find "My report 02" in the app
And I press "My report 02" in the app
And I should find "My report 02" in the app
And I should find "Lionel Smith" in the app
But I should not find "My report 03" in the app