From 49e04914284354ec4062aeafe5d8d5e856e839e2 Mon Sep 17 00:00:00 2001
From: Noel De Martin <noel@moodle.com>
Date: Mon, 14 Nov 2022 17:52:47 +0100
Subject: [PATCH] MOBILE-4176 grades: Fix 4.1 layout

Fixes some breaking changes introduced in MDL-75513. The fixes included here are not exhaustive but should take care of the basic scenarios covered by e2e tests. Subsequent fixes will be provided to handle other edge-cases.
---
 .../features/grades/pages/course/course.html  | 43 +++++-----
 .../grades/pages/course/course.page.ts        | 17 +++-
 .../features/grades/pages/course/course.scss  |  8 ++
 .../features/grades/services/grades-helper.ts | 81 +++++++++++++++----
 4 files changed, 114 insertions(+), 35 deletions(-)

diff --git a/src/core/features/grades/pages/course/course.html b/src/core/features/grades/pages/course/course.html
index bf4835c89..3919f3926 100644
--- a/src/core/features/grades/pages/course/course.html
+++ b/src/core/features/grades/pages/course/course.html
@@ -27,13 +27,18 @@
                 </thead>
                 <tbody>
                     <ng-container *ngFor="let row of rows">
+                        <tr *ngIf="!useLegacyLayout && row.itemtype === 'leader'">
+                            <td [attr.rowspan]="row.rowspan" class="core-grades-table-leader"></td>
+                        </tr>
                         <tr [attr.role]="row.expandable && showSummary ? 'button row' : 'row'"
                             [attr.tabindex]="row.expandable && showSummary && 0" [attr.aria-expanded]="row.expanded"
                             [attr.aria-label]="rowAriaLabel(row)" [attr.aria-controls]="row.detailsid"
                             (ariaButtonClick)="row.expandable && showSummary && toggleRow(row)" [class]="row.rowclass"
-                            [class.core-grades-grade-clickable]="row.expandable && showSummary" [id]="'grade-'+row.id">
+                            [class.core-grades-grade-clickable]="row.expandable && showSummary" [id]="'grade-'+row.id"
+                            *ngIf="useLegacyLayout || row.itemtype !== 'leader'">
                             <ng-container *ngIf="row.itemtype">
-                                <td *ngIf="row.itemtype == 'category'" class="core-grades-table-category" [attr.rowspan]="row.rowspan">
+                                <td *ngIf="!useLegacyLayout && row.itemtype == 'category'" class="core-grades-table-category"
+                                    [attr.rowspan]="row.rowspan">
                                 </td>
                                 <th class="core-grades-table-gradeitem ion-text-start" [attr.colspan]="row.colspan">
                                     <ion-icon *ngIf="row.expandable && showSummary" aria-hidden="true" slot="start" name="fas-chevron-right"
@@ -48,23 +53,25 @@
                                     </core-mod-icon>
                                     <span [innerHTML]="row.gradeitem"></span>
                                 </th>
-                                <ng-container *ngFor="let column of columns">
-                                    <td *ngIf="column.name !== 'gradeitem' && column.name !== 'feedback' && column.name !== 'grade' &&
+                                <ng-container *ngIf="row.itemtype !== 'category'">
+                                    <ng-container *ngFor="let column of columns">
+                                        <td *ngIf="column.name !== 'gradeitem' && column.name !== 'feedback' && column.name !== 'grade' &&
                                         row[column.name] != undefined" [class]="'ion-text-start core-grades-table-' + column.name"
-                                        [class.ion-hide-md-down]="column.hiddenPhone" [innerHTML]="row[column.name]">
-                                    </td>
-                                    <td *ngIf="column.name === 'feedback' && row.feedback !== undefined"
-                                        class="ion-text-start core-grades-table-feedback" [class.ion-hide-md-down]="column.hiddenPhone">
-                                        <core-format-text collapsible-item [text]="row.feedback" contextLevel="course"
-                                            [contextInstanceId]="courseId">
-                                        </core-format-text>
-                                    </td>
-                                    <td *ngIf="column.name === 'grade'" [class.ion-hide-md-down]="column.hiddenPhone"
-                                        class="ion-text-start core-grades-table-grade {{row.gradeClass}}">
-                                        <ion-icon *ngIf="row.gradeIcon" [name]="row.gradeIcon" [attr.aria-label]="row.gradeIconAlt">
-                                        </ion-icon>
-                                        <span [innerHTML]="row[column.name]"></span>
-                                    </td>
+                                            [class.ion-hide-md-down]="column.hiddenPhone" [innerHTML]="row[column.name]">
+                                        </td>
+                                        <td *ngIf="column.name === 'feedback' && row.feedback !== undefined"
+                                            class="ion-text-start core-grades-table-feedback" [class.ion-hide-md-down]="column.hiddenPhone">
+                                            <core-format-text collapsible-item [text]="row.feedback" contextLevel="course"
+                                                [contextInstanceId]="courseId">
+                                            </core-format-text>
+                                        </td>
+                                        <td *ngIf="column.name === 'grade'" [class.ion-hide-md-down]="column.hiddenPhone"
+                                            class="ion-text-start core-grades-table-grade {{row.gradeClass}}">
+                                            <ion-icon *ngIf="row.gradeIcon" [name]="row.gradeIcon" [attr.aria-label]="row.gradeIconAlt">
+                                            </ion-icon>
+                                            <span [innerHTML]="row[column.name]"></span>
+                                        </td>
+                                    </ng-container>
                                 </ng-container>
                             </ng-container>
                         </tr>
diff --git a/src/core/features/grades/pages/course/course.page.ts b/src/core/features/grades/pages/course/course.page.ts
index f3d57d541..2f85483de 100644
--- a/src/core/features/grades/pages/course/course.page.ts
+++ b/src/core/features/grades/pages/course/course.page.ts
@@ -54,6 +54,7 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy {
     rows?: CoreGradesFormattedTableRow[];
     totalColumnsSpan?: number;
     withinSplitView?: boolean;
+    useLegacyLayout?: boolean; // Whether to use the layout before 4.1.
 
     protected fetchSuccess = false;
 
@@ -68,6 +69,7 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy {
 
             this.expandLabel = Translate.instant('core.expand');
             this.collapseLabel = Translate.instant('core.collapse');
+            this.useLegacyLayout = !CoreSites.getRequiredCurrentSite().isVersionGreaterEqualThan('4.1');
 
             if (route.snapshot.data.swipeEnabled ?? true) {
                 const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(CoreGradesCoursesSource, []);
@@ -133,11 +135,22 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy {
 
         row.expanded = expand ?? !row.expanded;
 
-        let colspan: number = this.columns.length + (row.colspan ?? 0) - 1;
+        let colspan: number = this.columns.length + (row.colspan ?? 0);
+
+        if (this.useLegacyLayout) {
+            colspan--;
+        }
+
         for (let i = this.rows.indexOf(row) - 1; i >= 0; i--) {
             const previousRow = this.rows[i];
 
-            if (previousRow.expandable || !previousRow.colspan || !previousRow.rowspan || previousRow.colspan !== colspan) {
+            if (
+                !previousRow.rowspan ||
+                !previousRow.colspan ||
+                previousRow.colspan !== colspan ||
+                (!this.useLegacyLayout && previousRow.itemtype !== 'leader') ||
+                (this.useLegacyLayout && previousRow.expandable)
+            ) {
                 continue;
             }
 
diff --git a/src/core/features/grades/pages/course/course.scss b/src/core/features/grades/pages/course/course.scss
index 5358730a6..096eb096d 100644
--- a/src/core/features/grades/pages/course/course.scss
+++ b/src/core/features/grades/pages/course/course.scss
@@ -124,6 +124,14 @@
         }
     }
 
+    .core-grades-table-leader {
+        width: 0;
+    }
+
+    .ion-no-border {
+        border: 0 !important;
+    }
+
     .dimmed_text,
     .hidden {
         opacity: .7;
diff --git a/src/core/features/grades/services/grades-helper.ts b/src/core/features/grades/services/grades-helper.ts
index f417a20a3..720ae5c45 100644
--- a/src/core/features/grades/services/grades-helper.ts
+++ b/src/core/features/grades/services/grades-helper.ts
@@ -25,6 +25,7 @@ import {
     CoreGradesTable,
     CoreGradesTableColumn,
     CoreGradesTableItemNameColumn,
+    CoreGradesTableLeaderColumn,
     CoreGradesTableRow,
 } from '@features/grades/services/grades';
 import { CoreTextUtils } from '@services/utils/text';
@@ -71,7 +72,8 @@ export class CoreGradesHelperProvider {
             let content = String(column.content);
 
             if (name == 'itemname') {
-                await this.setRowIcon(row, content);
+                this.setRowIconAndType(row, content);
+
                 row.link = this.getModuleLink(content);
                 row.rowclass += column.class.indexOf('hidden') >= 0 ? ' hidden' : '';
                 row.rowclass += column.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : '';
@@ -96,10 +98,23 @@ export class CoreGradesHelperProvider {
      * Formats a row from the grades table to be rendered in one table.
      *
      * @param tableRow JSON object representing row of grades table data.
+     * @param useLegacyLayout Whether to use the layout before 4.1.
      * @return Formatted row object.
      */
-    protected formatGradeRowForTable(tableRow: CoreGradesTableRow): CoreGradesFormattedTableRow {
+    protected formatGradeRowForTable(tableRow: CoreGradesTableRow, useLegacyLayout: boolean): CoreGradesFormattedTableRow {
         const row: CoreGradesFormattedTableRow = {};
+
+        if (!useLegacyLayout && 'leader' in tableRow) {
+            const row = {
+                itemtype: 'leader',
+                rowspan: tableRow.leader?.rowspan,
+            };
+
+            this.setRowEvenOddClass(row, (tableRow.leader as CoreGradesTableLeaderColumn).class);
+
+            return row;
+        }
+
         for (let name in tableRow) {
             const column: CoreGradesTableColumn = tableRow[name];
 
@@ -116,13 +131,13 @@ export class CoreGradesHelperProvider {
                 row.colspan = itemNameColumn.colspan;
                 row.rowspan = tableRow.leader?.rowspan || 1;
 
-                this.setRowIcon(row, content);
-                row.rowclass = itemNameColumn.class.indexOf('leveleven') < 0 ? 'odd' : 'even';
+                this.setRowIconAndType(row, content);
+                this.setRowEvenOddClass(row, itemNameColumn.class);
                 row.rowclass += itemNameColumn.class.indexOf('hidden') >= 0 ? ' hidden' : '';
                 row.rowclass += itemNameColumn.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : '';
 
                 content = content.replace(/<\/span>/gi, '\n');
-                content = CoreTextUtils.cleanTags(content);
+                content = CoreTextUtils.cleanTags(content, true);
                 name = 'gradeitem';
             } else if (name === 'grade') {
                 // Add the pass/fail class if present.
@@ -202,7 +217,7 @@ export class CoreGradesHelperProvider {
             feedback: false,
             contributiontocoursetotal: false,
         };
-        formatted.rows = table.tabledata.map(row => this.formatGradeRowForTable(row));
+        formatted.rows = this.formatGradesTableRows(table.tabledata);
 
         // Get a row with some info.
         let normalRow = formatted.rows.find(
@@ -234,6 +249,33 @@ export class CoreGradesHelperProvider {
         return formatted;
     }
 
+    /**
+     * Format table rows.
+     *
+     * @param rows Unformatted rows.
+     * @returns Formatted rows.
+     */
+    protected formatGradesTableRows(rows: CoreGradesTableRow[]): CoreGradesFormattedTableRow[] {
+        const useLegacyLayout = !CoreSites.getRequiredCurrentSite().isVersionGreaterEqualThan('4.1');
+        const formattedRows = rows.map(row => this.formatGradeRowForTable(row, useLegacyLayout));
+
+        if (!useLegacyLayout) {
+            for (let index = 0; index < formattedRows.length - 1; index++) {
+                const row = formattedRows[index];
+                const previousRow = formattedRows[index - 1] ?? null;
+
+                if (row.itemtype !== 'leader') {
+                    continue;
+                }
+
+                row.colspan = previousRow.colspan;
+                previousRow.rowclass = `${previousRow.rowclass ?? ''} ion-no-border`.trim();
+            }
+        }
+
+        return formattedRows;
+    }
+
     /**
      * Get course data for grades since they only have courseid.
      *
@@ -474,7 +516,7 @@ export class CoreGradesHelperProvider {
         // Find href containing "/mod/xxx/xxx.php".
         const regex = /href="([^"]*\/mod\/[^"|^/]*\/[^"|^.]*\.php[^"]*)/;
 
-        return table.tabledata.filter((row) => {
+        return this.formatGradesTableRows(table.tabledata.filter((row) => {
             if (row.itemname && row.itemname.content) {
                 const matches = row.itemname.content.match(regex);
 
@@ -486,7 +528,7 @@ export class CoreGradesHelperProvider {
             }
 
             return false;
-        }).map((row) => this.formatGradeRowForTable(row));
+        }));
     }
 
     /**
@@ -582,14 +624,25 @@ export class CoreGradesHelperProvider {
         return CoreGrades.invalidateCourseGradesItemsData(courseId, userId, groupId, siteId);
     }
 
+    /**
+     * Set 'odd' or 'even' classes into a row.
+     *
+     * @param row Row.
+     * @param classes Existing row classes.
+     */
+    protected setRowEvenOddClass(row: CoreGradesFormattedTableRow, classes: string): void {
+        const level = parseInt(classes.match(/(?:^|\s)level(\d+)(?:$|\s)/)?.[1] ?? '0');
+
+        row.rowclass = `${row.rowclass ?? ''} ${level % 2 === 0 ? 'even' : 'odd'}`.trim();
+    }
+
     /**
      * Parses the image and sets it to the row.
      *
-     * @param row Formatted grade row object.
-     * @param text HTML where the image will be rendered.
-     * @return Row object with the image.
+     * @param row Row.
+     * @param text Row content.
      */
-    protected setRowIcon<T extends CoreGradesFormattedRowCommonData>(row: T, text: string): T {
+    protected setRowIconAndType(row: CoreGradesFormattedRowCommonData, text: string): void {
         text = text.replace('%2F', '/').replace('%2f', '/');
         if (text.indexOf('/agg_mean') > -1) {
             row.itemtype = 'agg_mean';
@@ -603,7 +656,7 @@ export class CoreGradesHelperProvider {
             row.itemtype = 'outcome';
             row.icon = 'fas-tasks';
             row.iconAlt = Translate.instant('core.grades.outcome');
-        } else if (text.indexOf('i/folder') > -1 || text.indexOf('fa-folder') > -1) {
+        } else if (text.indexOf('i/folder') > -1 || text.indexOf('fa-folder') > -1 || text.indexOf('category-content') > -1) {
             row.itemtype = 'category';
             row.icon = 'fas-folder';
             row.iconAlt = Translate.instant('core.grades.category');
@@ -643,8 +696,6 @@ export class CoreGradesHelperProvider {
                 row.iconAlt = Translate.instant('core.unknown');
             }
         }
-
-        return row;
     }
 
     /**