diff --git a/scripts/langindex.json b/scripts/langindex.json index 7d2407912..325e56d42 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -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", diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index 7b5d7e569..6c4e3240e 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -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, diff --git a/src/core/features/reportbuilder/classes/reports-source.ts b/src/core/features/reportbuilder/classes/reports-source.ts new file mode 100644 index 000000000..31449689f --- /dev/null +++ b/src/core/features/reportbuilder/classes/reports-source.ts @@ -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 { + + /** + * @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; + } + +} diff --git a/src/core/features/reportbuilder/components/components.module.ts b/src/core/features/reportbuilder/components/components.module.ts new file mode 100644 index 000000000..f8f269fc3 --- /dev/null +++ b/src/core/features/reportbuilder/components/components.module.ts @@ -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 {} diff --git a/src/core/features/reportbuilder/components/report-column/report-column.html b/src/core/features/reportbuilder/components/report-column/report-column.html new file mode 100644 index 000000000..c5ee3a6ff --- /dev/null +++ b/src/core/features/reportbuilder/components/report-column/report-column.html @@ -0,0 +1,11 @@ + + +

{{ header }}

+ +
+ +
diff --git a/src/core/features/reportbuilder/components/report-column/report-column.scss b/src/core/features/reportbuilder/components/report-column/report-column.scss new file mode 100644 index 000000000..65df0f953 --- /dev/null +++ b/src/core/features/reportbuilder/components/report-column/report-column.scss @@ -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); + } +} diff --git a/src/core/features/reportbuilder/components/report-column/report-column.ts b/src/core/features/reportbuilder/components/report-column/report-column.ts new file mode 100644 index 000000000..cf5575c3d --- /dev/null +++ b/src/core/features/reportbuilder/components/report-column/report-column.ts @@ -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 = new EventEmitter(); + + /** + * Emits row click + */ + toggleRow(): void { + this.onToggleRow.emit(this.rowIndex); + } + +} diff --git a/src/core/features/reportbuilder/components/report-detail/report-detail.html b/src/core/features/reportbuilder/components/report-detail/report-detail.html new file mode 100644 index 000000000..13e856f82 --- /dev/null +++ b/src/core/features/reportbuilder/components/report-detail/report-detail.html @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ header }} +
+ + +
+
+ +
+ + + + + + + + + +
+ +
diff --git a/src/core/features/reportbuilder/components/report-detail/report-detail.scss b/src/core/features/reportbuilder/components/report-detail/report-detail.scss new file mode 100644 index 000000000..9851fcad6 --- /dev/null +++ b/src/core/features/reportbuilder/components/report-detail/report-detail.scss @@ -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; + } + + } +} diff --git a/src/core/features/reportbuilder/components/report-detail/report-detail.ts b/src/core/features/reportbuilder/components/report-detail/report-detail.ts new file mode 100644 index 000000000..992277102 --- /dev/null +++ b/src/core/features/reportbuilder/components/report-detail/report-detail.ts @@ -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(); + + get isCardLayout(): boolean { + return this.layout === 'card' || (CoreScreen.isMobile && this.layout === 'adaptative'); + } + + state$: Readonly> = new BehaviorSubject({ + report: null, + loaded: false, + canLoadMoreRows: true, + errorLoadingRows: false, + cardviewShowFirstTitle: false, + cardVisibleColumns: 1, + page: 0, + }); + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + await this.getReport(); + this.updateState({ loaded: true }); + } + + /** + * Get report data. + */ + async getReport(): Promise { + 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): void { + const previousState = this.state$.getValue(); + this.state$.next({ ...previousState, ...state }); + } + + /** + * Update report data. + * + * @param ionRefresher ionic refresher. + */ + async refreshReport(ionRefresher?: IonRefresher): Promise { + 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 { + 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; +}; diff --git a/src/core/features/reportbuilder/components/report-summary/report-summary.html b/src/core/features/reportbuilder/components/report-summary/report-summary.html new file mode 100644 index 000000000..7c37437c4 --- /dev/null +++ b/src/core/features/reportbuilder/components/report-summary/report-summary.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + +
+ + +

+ + +

+
+ + + +
+ + + +

{{ item.title | translate }}

+ + +
+
+
+
diff --git a/src/core/features/reportbuilder/components/report-summary/report-summary.ts b/src/core/features/reportbuilder/components/report-summary/report-summary.ts new file mode 100644 index 000000000..712914e3a --- /dev/null +++ b/src/core/features/reportbuilder/components/report-summary/report-summary.ts @@ -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(); + } + +} diff --git a/src/core/features/reportbuilder/lang.json b/src/core/features/reportbuilder/lang.json new file mode 100644 index 000000000..299a7a10f --- /dev/null +++ b/src/core/features/reportbuilder/lang.json @@ -0,0 +1,8 @@ +{ + "modifiedby": "Modified by", + "reportstab": "Reports", + "reportsource": "Report source", + "timecreated": "Time created", + "showcolumns": "Show columns", + "hidecolumns": "Hide columns" +} diff --git a/src/core/features/reportbuilder/pages/list/list.html b/src/core/features/reportbuilder/pages/list/list.html new file mode 100644 index 000000000..832fc49d5 --- /dev/null +++ b/src/core/features/reportbuilder/pages/list/list.html @@ -0,0 +1,35 @@ + + + + + + +

{{ 'core.reportbuilder.reportstab' | translate }}

+
+
+
+ + + + + + + + +

{{ report.name }}

+

{{ report.sourcename }}

+
+
+
+ + + + + + + + +
+
diff --git a/src/core/features/reportbuilder/pages/list/list.ts b/src/core/features/reportbuilder/pages/list/list.ts new file mode 100644 index 000000000..2538cb260 --- /dev/null +++ b/src/core/features/reportbuilder/pages/list/list.ts @@ -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; + + state$: Readonly> = 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 { + 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 { + 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): 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 { + 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 { + 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; +}; diff --git a/src/core/features/reportbuilder/pages/report/report.html b/src/core/features/reportbuilder/pages/report/report.html new file mode 100644 index 000000000..4dd499a70 --- /dev/null +++ b/src/core/features/reportbuilder/pages/report/report.html @@ -0,0 +1,21 @@ + + + + + + + + + + + +

{{ reportDetail.name }}

+

{{ reportDetail.sourcename }}

+
+
+
+ + + + + diff --git a/src/core/features/reportbuilder/pages/report/report.ts b/src/core/features/reportbuilder/pages/report/report.ts new file mode 100644 index 000000000..958509675 --- /dev/null +++ b/src/core/features/reportbuilder/pages/report/report.ts @@ -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({ + component: CoreReportBuilderReportSummaryComponent, + componentProps: { reportDetail: this.reportDetail }, + }); + } + +} diff --git a/src/core/features/reportbuilder/reportbuilder-lazy.module.ts b/src/core/features/reportbuilder/reportbuilder-lazy.module.ts new file mode 100644 index 000000000..c5064d388 --- /dev/null +++ b/src/core/features/reportbuilder/reportbuilder-lazy.module.ts @@ -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 {} diff --git a/src/core/features/reportbuilder/reportbuilder.module.ts b/src/core/features/reportbuilder/reportbuilder.module.ts new file mode 100644 index 000000000..833c8df1f --- /dev/null +++ b/src/core/features/reportbuilder/reportbuilder.module.ts @@ -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 {} diff --git a/src/core/features/reportbuilder/services/handlers/reportbuilder.ts b/src/core/features/reportbuilder/services/handlers/reportbuilder.ts new file mode 100644 index 000000000..4a463f46e --- /dev/null +++ b/src/core/features/reportbuilder/services/handlers/reportbuilder.ts @@ -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 { + return await CoreReportBuilder.isEnabled(); + } + + /** + * @inheritdoc + */ + getDisplayData(): CoreUserProfileHandlerData { + return { + class: 'core-report-builder', + icon: 'fa-list-alt', + title: 'core.reportbuilder.reportstab', + action: async (event): Promise => { + event.preventDefault(); + event.stopPropagation(); + await CoreNavigator.navigate(`/${CoreReportBuilderHandlerService.PAGE_NAME}`); + }, + }; + } + +} + +export const CoreReportBuilderHandler = makeSingleton(CoreReportBuilderHandlerService); diff --git a/src/core/features/reportbuilder/services/reportbuilder.ts b/src/core/features/reportbuilder/services/reportbuilder.ts new file mode 100644 index 000000000..2564e25cc --- /dev/null +++ b/src/core/features/reportbuilder/services/reportbuilder.ts @@ -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 { + const site = CoreSites.getRequiredCurrentSite(); + const preSets: CoreSiteWSPreSets = { cacheKey: this.getReportBuilderCacheKey() }; + const response = await site.read( + '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 { + const site = CoreSites.getRequiredCurrentSite(); + const preSets: CoreSiteWSPreSets = { cacheKey: this.getReportBuilderReportCacheKey() }; + const report = await site.read( + '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 { + const site = CoreSites.getRequiredCurrentSite(); + + await site.write('core_reportbuilder_view_report', { reportid }); + } + + /** + * Check if the feature is enabled or disabled. + * + * @returns Feature enabled or disabled. + */ + async isEnabled(): Promise { + 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 { + const site = CoreSites.getRequiredCurrentSite(); + await site.invalidateWsCacheForKey(this.getReportBuilderCacheKey()); + } + + /** + * Invalidates report WS calls. + * + * @returns Promise resolved when report is invalidated. + */ + async invalidateReport(): Promise { + 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 { + 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 { + settingsdata: CoreReportBuilderReportDetailSettingsData; +} + +export type CoreReportBuilderReportDetailSettingsData = { + cardviewShowFirstTitle: boolean; + cardviewVisibleColumns: number; +}; + +export interface CoreReportBuilderReport extends CoreReportBuilderReportWSResponse {}; diff --git a/src/core/features/reportbuilder/tests/behat/reportbuilder.feature b/src/core/features/reportbuilder/tests/behat/reportbuilder.feature new file mode 100644 index 000000000..ab128c38f --- /dev/null +++ b/src/core/features/reportbuilder/tests/behat/reportbuilder.feature @@ -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