MOBILE-3833 course: Collapse sections in downloads page

main
Dani Palou 2022-03-29 12:39:18 +02:00
parent 37af8e3c69
commit 0298273fc4
7 changed files with 152 additions and 100 deletions

View File

@ -47,7 +47,13 @@
<ng-container *ngFor="let section of sections"> <ng-container *ngFor="let section of sections">
<ion-card class="section" *ngIf="section.modules.length > 0"> <ion-card class="section" *ngIf="section.modules.length > 0">
<ion-card-header> <ion-card-header>
<ion-item class="ion-no-padding" lines="full"> <ion-item class="ion-no-padding" [lines]="section.expanded ? 'full' : 'none'" button detail="false"
(click)="toggleExpand($event, section)" [class.core-course-storage-section-expanded]="section.expanded"
[attr.aria-label]="(section.expanded ? 'core.collapse' : 'core.expand') | translate"
[attr.aria-expanded]="section.expanded" [attr.aria-controls]="'core-course-storage-section-' + section.id">
<ion-icon name="fas-chevron-right" flip-rtl slot="start" class="expandable-status-icon"
[class.expandable-status-icon-expanded]="section.expanded">
</ion-icon>
<ion-label> <ion-label>
<p class="item-heading ion-text-wrap"> <p class="item-heading ion-text-wrap">
<core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="section.course" <core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="section.course"
@ -93,44 +99,46 @@
</div> </div>
</ion-item> </ion-item>
</ion-card-header> </ion-card-header>
<ion-card-content> <ion-card-content id="core-course-storage-section-{{section.id}}">
<ng-container *ngFor="let module of section.modules"> <ng-container *ngIf="section.expanded">
<ion-item class="ion-no-padding core-course-storage-activity" <ng-container *ngFor="let module of section.modules">
*ngIf="downloadEnabled || (!module.calculatingSize && module.totalSize > 0)"> <ion-item class="ion-no-padding core-course-storage-activity"
<core-mod-icon slot="start" *ngIf="module.handlerData.icon" [modicon]="module.handlerData.icon" *ngIf="downloadEnabled || (!module.calculatingSize && module.totalSize > 0)">
[modname]="module.modname" [componentId]="module.instance"> <core-mod-icon slot="start" *ngIf="module.handlerData.icon" [modicon]="module.handlerData.icon"
</core-mod-icon> [modname]="module.modname" [componentId]="module.instance">
<ion-label class="ion-text-wrap"> </core-mod-icon>
<h3 class="{{module.handlerData!.class}} addon-storagemanager-module-size"> <ion-label class="ion-text-wrap">
<core-format-text [text]="module.handlerData.title" [courseId]="module.course" contextLevel="module" <h3 class="{{module.handlerData!.class}} addon-storagemanager-module-size">
[contextInstanceId]="module.id" [adaptImg]="false"> <core-format-text [text]="module.handlerData.title" [courseId]="module.course" contextLevel="module"
</core-format-text> [contextInstanceId]="module.id" [adaptImg]="false">
</h3> </core-format-text>
<ion-badge [color]="module.downloadStatus == statusDownloaded ? 'success' : 'light'" </h3>
*ngIf="!module.calculatingSize && module.totalSize > 0"> <ion-badge [color]="module.downloadStatus == statusDownloaded ? 'success' : 'light'"
<ion-icon name="fam-cloud-done" *ngIf="module.downloadStatus == statusDownloaded" *ngIf="!module.calculatingSize && module.totalSize > 0">
[attr.aria-label]="'core.downloaded' | translate"> <ion-icon name="fam-cloud-done" *ngIf="module.downloadStatus == statusDownloaded"
</ion-icon>{{ module.totalSize | coreBytesToSize }} [attr.aria-label]="'core.downloaded' | translate">
</ion-badge> </ion-icon>{{ module.totalSize | coreBytesToSize }}
<ion-badge color="light" *ngIf="module.calculatingSize"> </ion-badge>
{{ 'core.calculating' | translate }} <ion-badge color="light" *ngIf="module.calculatingSize">
</ion-badge> {{ 'core.calculating' | translate }}
</ion-label> </ion-badge>
</ion-label>
<div class="storage-buttons" slot="end"> <div class="storage-buttons" slot="end">
<core-download-refresh *ngIf="downloadEnabled && module.handlerData?.showDownloadButton && <core-download-refresh *ngIf="downloadEnabled && module.handlerData?.showDownloadButton &&
module.downloadStatus != statusDownloaded" [status]="module.downloadStatus" [enabled]="true" module.downloadStatus != statusDownloaded" [status]="module.downloadStatus" [enabled]="true"
[canTrustDownload]="true" [loading]="module.spinner || module.handlerData.spinner" [canTrustDownload]="true" [loading]="module.spinner || module.handlerData.spinner"
(action)="prefetchModule(module, section)"> (action)="prefetchModule(module, section)">
</core-download-refresh> </core-download-refresh>
<ion-button fill="clear" (click)="deleteForModule(module, section)" <ion-button fill="clear" (click)="deleteForModule(module, section)"
*ngIf="!module.calculatingSize && module.totalSize > 0" color="danger"> *ngIf="!module.calculatingSize && module.totalSize > 0" color="danger">
<ion-icon name="fas-trash" slot="icon-only" <ion-icon name="fas-trash" slot="icon-only"
[attr.aria-label]="'addon.storagemanager.deletedatafrom' | translate: { name: module.name }"> [attr.aria-label]="'addon.storagemanager.deletedatafrom' | translate: { name: module.name }">
</ion-icon> </ion-icon>
</ion-button> </ion-button>
</div> </div>
</ion-item> </ion-item>
</ng-container>
</ng-container> </ng-container>
</ion-card-content> </ion-card-content>
</ion-card> </ion-card>

View File

@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
import { CoreConstants } from '@/core/constants'; import { CoreConstants } from '@/core/constants';
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, ElementRef, OnDestroy, OnInit } from '@angular/core';
import { CoreCourse, CoreCourseProvider } from '@features/course/services/course'; import { CoreCourse, CoreCourseProvider } from '@features/course/services/course';
import { import {
CoreCourseHelper, CoreCourseHelper,
@ -30,6 +30,7 @@ import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons'; import { Translate } from '@singletons';
import { CoreDom } from '@singletons/dom';
import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEventObserver, CoreEvents } from '@singletons/events';
/** /**
@ -62,6 +63,7 @@ export class AddonStorageManagerCourseStoragePage implements OnInit, OnDestroy {
statusDownloaded = CoreConstants.DOWNLOADED; statusDownloaded = CoreConstants.DOWNLOADED;
protected initialSectionId?: number;
protected siteUpdatedObserver?: CoreEventObserver; protected siteUpdatedObserver?: CoreEventObserver;
protected courseStatusObserver?: CoreEventObserver; protected courseStatusObserver?: CoreEventObserver;
protected sectionStatusObserver?: CoreEventObserver; protected sectionStatusObserver?: CoreEventObserver;
@ -69,7 +71,7 @@ export class AddonStorageManagerCourseStoragePage implements OnInit, OnDestroy {
protected isDestroyed = false; protected isDestroyed = false;
protected isGuest = false; protected isGuest = false;
constructor() { constructor(protected elementRef: ElementRef) {
// Refresh the enabled flags if site is updated. // Refresh the enabled flags if site is updated.
this.siteUpdatedObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { this.siteUpdatedObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite(); this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
@ -100,16 +102,19 @@ export class AddonStorageManagerCourseStoragePage implements OnInit, OnDestroy {
} }
this.isGuest = !!CoreNavigator.getRouteBooleanParam('isGuest'); this.isGuest = !!CoreNavigator.getRouteBooleanParam('isGuest');
this.initialSectionId = CoreNavigator.getRouteNumberParam('sectionId');
this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite(); this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
this.downloadEnabled = !CoreSites.getRequiredCurrentSite().isOfflineDisabled(); this.downloadEnabled = !CoreSites.getRequiredCurrentSite().isOfflineDisabled();
const sections = await CoreCourse.getSections(this.courseId, false, true); const sections = (await CoreCourse.getSections(this.courseId, false, true))
.filter((section) => !CoreCourseHelper.isSectionStealth(section));
this.sections = (await CoreCourseHelper.addHandlerDataForModules(sections, this.courseId)).sections this.sections = (await CoreCourseHelper.addHandlerDataForModules(sections, this.courseId)).sections
.map(section => ({ .map(section => ({
...section, ...section,
totalSize: 0, totalSize: 0,
calculatingSize: true, calculatingSize: true,
expanded: section.id === this.initialSectionId,
modules: section.modules.map(module => ({ modules: section.modules.map(module => ({
...module, ...module,
calculatingSize: true, calculatingSize: true,
@ -118,6 +123,12 @@ export class AddonStorageManagerCourseStoragePage implements OnInit, OnDestroy {
this.loaded = true; this.loaded = true;
CoreDom.scrollToElement(
this.elementRef.nativeElement,
'.core-course-storage-section-expanded',
{ addYAxis: -10 },
);
await Promise.all([ await Promise.all([
this.initSizes(), this.initSizes(),
this.initCoursePrefetch(), this.initCoursePrefetch(),
@ -641,6 +652,18 @@ export class AddonStorageManagerCourseStoragePage implements OnInit, OnDestroy {
} }
} }
/**
* Toggle expand status.
*
* @param event Event object.
* @param section Section to expand / collapse.
*/
toggleExpand(event: Event, section: AddonStorageManagerCourseSection): void {
section.expanded = !section.expanded;
event.stopPropagation();
event.preventDefault();
}
/** /**
* @inheritdoc * @inheritdoc
*/ */
@ -663,6 +686,7 @@ export class AddonStorageManagerCourseStoragePage implements OnInit, OnDestroy {
type AddonStorageManagerCourseSection = Omit<CoreCourseSectionWithStatus, 'modules'> & { type AddonStorageManagerCourseSection = Omit<CoreCourseSectionWithStatus, 'modules'> & {
totalSize: number; totalSize: number;
calculatingSize: boolean; calculatingSize: boolean;
expanded: boolean;
modules: AddonStorageManagerModule[]; modules: AddonStorageManagerModule[];
}; };

View File

@ -1,3 +1,8 @@
<core-navbar-buttons slot="end" prepend>
<ion-button fill="clear" (click)="gotoCourseDownloads()" [attr.aria-label]="'addon.storagemanager.coursedownloads' | translate">
<ion-icon name="fas-cloud-download-alt" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</core-navbar-buttons>
<core-dynamic-component [component]="courseFormatComponent" [data]="data"> <core-dynamic-component [component]="courseFormatComponent" [data]="data">
<!-- Default course format. --> <!-- Default course format. -->
<core-loading [hideUntil]="loaded"> <core-loading [hideUntil]="loaded">

View File

@ -391,29 +391,38 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
); );
} }
/**
* Get selected section ID. If viewing all sections, use current scrolled section.
*
* @return Section ID, undefined if not found.
*/
protected async getSelectedSectionId(): Promise<number | undefined> {
if (this.selectedSection?.id !== this.allSectionsId) {
return this.selectedSection?.id;
}
// Check current scrolled section.
const allSectionElements: NodeListOf<HTMLElement> =
this.elementRef.nativeElement.querySelectorAll('section.core-course-module-list-wrapper');
const scroll = await this.content.getScrollElement();
const containerTop = scroll.getBoundingClientRect().top;
const element = Array.from(allSectionElements).find((element) => {
const position = element.getBoundingClientRect();
// The bottom is inside the container or lower.
return position.bottom >= containerTop;
});
return Number(element?.getAttribute('id')) || undefined;
}
/** /**
* Display the course index modal. * Display the course index modal.
*/ */
async openCourseIndex(): Promise<void> { async openCourseIndex(): Promise<void> {
let selectedId = this.selectedSection?.id; const selectedId = await this.getSelectedSectionId();
if (selectedId == this.allSectionsId) {
// Check current scrolled section.
const allSectionElements: NodeListOf<HTMLElement> =
this.elementRef.nativeElement.querySelectorAll('section.section-wrapper');
const scroll = await this.content.getScrollElement();
const containerTop = scroll.getBoundingClientRect().top;
const element = Array.from(allSectionElements).find((element) => {
const position = element.getBoundingClientRect();
// The bottom is inside the container or lower.
return position.bottom >= containerTop;
});
selectedId = Number(element?.getAttribute('id')) || undefined;
}
const data = await CoreDomUtils.openModal<CoreCourseIndexSectionWithModule>({ const data = await CoreDomUtils.openModal<CoreCourseIndexSectionWithModule>({
component: CoreCourseCourseIndexComponent, component: CoreCourseCourseIndexComponent,
@ -453,6 +462,23 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
this.moduleId = data.moduleId; this.moduleId = data.moduleId;
} }
/**
* Open course downloads page.
*/
async gotoCourseDownloads(): Promise<void> {
const selectedId = await this.getSelectedSectionId();
CoreNavigator.navigateToSitePath(
`storage/${this.course.id}`,
{
params: {
title: this.course.fullname,
sectionId: selectedId,
},
},
);
}
/** /**
* Function called when selected section changes. * Function called when selected section changes.
* *

View File

@ -48,34 +48,36 @@
<ion-icon name="fas-eye-slash" *ngIf="!section.visible && section.uservisible" slot="end" class="restricted" <ion-icon name="fas-eye-slash" *ngIf="!section.visible && section.uservisible" slot="end" class="restricted"
[attr.aria-label]="'core.course.hiddenfromstudents' | translate"></ion-icon> [attr.aria-label]="'core.course.hiddenfromstudents' | translate"></ion-icon>
</ion-item> </ion-item>
<ng-container *ngIf="section.expanded"> <div id="core-course-index-section-{{section.id}}">
<ng-container *ngFor="let module of section.modules"> <ng-container *ngIf="section.expanded">
<ion-item class="module" [class.item-dimmed]="!module.visible" [class.item-hightlighted]="section.highlighted" <ng-container *ngFor="let module of section.modules">
(click)="selectSectionOrModule($event, section.id, module.id)" button> <ion-item class="module" [class.item-dimmed]="!module.visible" [class.item-hightlighted]="section.highlighted"
<ion-icon class="completioninfo completion_none" name="" *ngIf="module.completionStatus === undefined" (click)="selectSectionOrModule($event, section.id, module.id)" button>
slot="start" aria-hidden="true"></ion-icon> <ion-icon class="completioninfo completion_none" name="" *ngIf="module.completionStatus === undefined"
<ion-icon class="completioninfo completion_incomplete" name="far-circle" *ngIf="module.completionStatus === 0" slot="start" aria-hidden="true"></ion-icon>
slot="start" [attr.aria-label]="'core.course.todo' | translate"> <ion-icon class="completioninfo completion_incomplete" name="far-circle"
</ion-icon> *ngIf="module.completionStatus === 0" slot="start" [attr.aria-label]="'core.course.todo' | translate">
<ion-icon class="completioninfo completion_complete" name="fas-circle" </ion-icon>
*ngIf="module.completionStatus === 1 || module.completionStatus === 2" color="success" slot="start" <ion-icon class="completioninfo completion_complete" name="fas-circle"
[attr.aria-label]="'core.course.done' | translate"> *ngIf="module.completionStatus === 1 || module.completionStatus === 2" color="success" slot="start"
</ion-icon> [attr.aria-label]="'core.course.done' | translate">
<ion-icon class="completioninfo completion_fail" name="fas-circle" *ngIf="module.completionStatus === 3" </ion-icon>
color="danger" slot="start" [attr.aria-label]="'core.course.failed' | translate"> <ion-icon class="completioninfo completion_fail" name="fas-circle" *ngIf="module.completionStatus === 3"
</ion-icon> color="danger" slot="start" [attr.aria-label]="'core.course.failed' | translate">
<ion-label> </ion-icon>
<p class="item-heading"> <ion-label>
<core-format-text [text]="module.name" contextLevel="module" [contextInstanceId]="module.id" <p class="item-heading">
[courseId]="module.course"> <core-format-text [text]="module.name" contextLevel="module" [contextInstanceId]="module.id"
</core-format-text> [courseId]="module.course">
</p> </core-format-text>
</ion-label> </p>
<ion-icon name="fas-lock" *ngIf="!module.uservisible" slot="end" class="restricted" </ion-label>
[attr.aria-label]="'core.restricted' | translate"></ion-icon> <ion-icon name="fas-lock" *ngIf="!module.uservisible" slot="end" class="restricted"
</ion-item> [attr.aria-label]="'core.restricted' | translate"></ion-icon>
</ion-item>
</ng-container>
</ng-container> </ng-container>
</ng-container> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ion-list> </ion-list>

View File

@ -1,8 +1,3 @@
<core-navbar-buttons slot="end" prepend>
<ion-button fill="clear" (click)="gotoCourseDownloads()" [attr.aria-label]="'addon.storagemanager.coursedownloads' | translate">
<ion-icon name="fas-cloud-download-alt" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</core-navbar-buttons>
<ion-content> <ion-content>
<ion-refresher slot="fixed" [disabled]="!dataLoaded || !displayRefresher" (ionRefresh)="doRefresh($event.target)"> <ion-refresher slot="fixed" [disabled]="!dataLoaded || !displayRefresher" (ionRefresh)="doRefresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>

View File

@ -366,14 +366,6 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy {
} }
} }
gotoCourseDownloads(): void {
CoreNavigator.navigateToSitePath(
`storage/${this.course.id}`,
{ params: { title: this.course.fullname } },
);
}
/** /**
* @inheritdoc * @inheritdoc
*/ */