MOBILE-4636 course: Expand and collapse sections

main
Pau Ferrer Ocaña 2024-08-21 15:15:15 +02:00
parent 074246f715
commit f0ef97b377
7 changed files with 163 additions and 49 deletions

View File

@ -112,6 +112,13 @@ export class CoreInfiniteLoadingComponent implements OnChanges {
this.action.emit(() => this.complete()); this.action.emit(() => this.complete());
} }
/**
* Fire the infinite scroll load more action if needed.
*/
async fireInfiniteScrollIfNeeded(): Promise<void> {
this.checkScrollDistance();
}
/** /**
* Complete loading. * Complete loading.
*/ */

View File

@ -10,7 +10,9 @@
<!-- Single section. --> <!-- Single section. -->
<div *ngIf="selectedSection && selectedSection.id !== allSectionsId" class="single-section list-item-limited-width"> <div *ngIf="selectedSection && selectedSection.id !== allSectionsId" class="single-section list-item-limited-width">
<core-dynamic-component [component]="singleSectionComponent" [data]="data"> <core-dynamic-component [component]="singleSectionComponent" [data]="data">
<ng-container *ngTemplateOutlet="sectionTemplate; context: {section: selectedSection}" /> <ion-accordion-group [readonly]="true" value="single">
<ng-container *ngTemplateOutlet="sectionTemplate; context: {section: selectedSection, sectionId: 'single'}" />
</ion-accordion-group>
<core-empty-box *ngIf="!selectedSection.hasContent" icon="fas-table-cells-large" <core-empty-box *ngIf="!selectedSection.hasContent" icon="fas-table-cells-large"
[message]="'core.course.nocontentavailable' | translate" /> [message]="'core.course.nocontentavailable' | translate" />
</core-dynamic-component> </core-dynamic-component>
@ -19,11 +21,14 @@
<!-- Multiple sections. --> <!-- Multiple sections. -->
<div *ngIf="selectedSection && selectedSection.id === allSectionsId" class="multiple-sections list-item-limited-width"> <div *ngIf="selectedSection && selectedSection.id === allSectionsId" class="multiple-sections list-item-limited-width">
<core-dynamic-component [component]="allSectionsComponent" [data]="data"> <core-dynamic-component [component]="allSectionsComponent" [data]="data">
<ng-container *ngFor="let section of sections; index as i"> <ion-accordion-group [multiple]="true" (ionChange)="accordionMultipleChange($event.detail)" [value]="accordionMultipleValue"
<ng-container *ngIf="i <= lastShownSectionIndex"> #accordionMultiple>
<ng-container *ngTemplateOutlet="sectionTemplate; context: {section: section}" /> @for (section of sections; track section.id) {
</ng-container> @if ($index <= lastShownSectionIndex) {
</ng-container> <ng-container *ngTemplateOutlet="sectionTemplate; context: {section: section, sectionId: section.id}" />
}
}
</ion-accordion-group>
</core-dynamic-component> </core-dynamic-component>
<core-infinite-loading [enabled]="canLoadMore" (action)="showMoreActivities($event)" /> <core-infinite-loading [enabled]="canLoadMore" (action)="showMoreActivities($event)" />
@ -62,12 +67,12 @@
</ion-fab> </ion-fab>
<!-- Template to render a section. --> <!-- Template to render a section. -->
<ng-template #sectionTemplate let-section="section"> <ng-template #sectionTemplate let-section="section" let-sectionId="sectionId">
<section *ngIf="!section.hiddenbynumsections && section.id !== allSectionsId && section.id !== stealthModulesSectionId" <ion-accordion *ngIf="!section.hiddenbynumsections && section.id !== allSectionsId && section.id !== stealthModulesSectionId"
class="core-course-module-list-wrapper" [id]="section.id" class="core-course-module-list-wrapper" [id]="section.id"
[attr.aria-labelledby]="section.name ? 'core-section-name-' + section.id : null"> [attr.aria-labelledby]="section.name ? 'core-section-name-' + section.id : null" [value]="''+sectionId" toggleIconSlot="start">
<ion-item-divider class="course-section ion-text-wrap" [class.item-dimmed]="section.visible === 0 || section.uservisible === false"> <ion-item class="course-section divider" [class.item-dimmed]="section.visible === 0 || section.uservisible === false" slot="header">
<ion-label> <ion-label class="ion-text-wrap">
<h2 *ngIf="section.name" class="big" [id]="'core-section-name-' + section.id"> <h2 *ngIf="section.name" class="big" [id]="'core-section-name-' + section.id">
<core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course.id" /> <core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course.id" />
</h2> </h2>
@ -91,8 +96,10 @@
</div> </div>
</ion-label> </ion-label>
<ion-badge *ngIf="section.highlighted && highlighted" slot="end">{{highlighted}}</ion-badge> <ion-badge *ngIf="section.highlighted && highlighted" slot="end">{{highlighted}}</ion-badge>
</ion-item-divider> </ion-item>
<div slot="content">
<ng-container *ngIf="section.expanded">
<ion-item class="ion-text-wrap section-summary" *ngIf="section.summary"> <ion-item class="ion-text-wrap section-summary" *ngIf="section.summary">
<ion-label> <ion-label>
<core-format-text [text]="section.summary" contextLevel="course" [contextInstanceId]="course.id" /> <core-format-text [text]="section.summary" contextLevel="course" [contextInstanceId]="course.id" />
@ -102,8 +109,11 @@
<ng-container *ngFor="let module of section.modules"> <ng-container *ngFor="let module of section.modules">
<core-course-module *ngIf="module.visibleoncoursepage !== 0" [module]="module" [section]="section" <core-course-module *ngIf="module.visibleoncoursepage !== 0" [module]="module" [section]="section"
[showActivityDates]="course.showactivitydates" [showCompletionConditions]="course.showcompletionconditions" [showActivityDates]="course.showactivitydates" [showCompletionConditions]="course.showcompletionconditions"
[isLastViewed]="lastModuleViewed && lastModuleViewed.cmId === module.id" [class.core-course-module-not-viewed]=" [isLastViewed]="lastModuleViewed && lastModuleViewed.cmId === module.id"
[class.core-course-module-not-viewed]="
!viewedModules[module.id] && (!module.completiondata || module.completiondata.state === completionStatusIncomplete)" /> !viewedModules[module.id] && (!module.completiondata || module.completiondata.state === completionStatusIncomplete)" />
</ng-container> </ng-container>
</section> </ng-container>
</div>
</ion-accordion>
</ng-template> </ng-template>

View File

@ -25,7 +25,15 @@
--ion-card-background: transparent; --ion-card-background: transparent;
} }
ion-item-divider { ion-item.divider.course-section {
--background: transparent; --background: transparent;
} }
} }
.single-section ::ng-deep {
ion-item.divider.course-section {
ion-icon.ion-accordion-toggle-icon {
display: none;
}
}
}

View File

@ -24,6 +24,7 @@ import {
Type, Type,
ElementRef, ElementRef,
ChangeDetectorRef, ChangeDetectorRef,
ViewChild,
} from '@angular/core'; } from '@angular/core';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component';
@ -39,7 +40,7 @@ import {
} from '@features/course/services/course-helper'; } from '@features/course/services/course-helper';
import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate'; import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate';
import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { IonContent } from '@ionic/angular'; import { AccordionGroupChangeEventDetail, IonContent } from '@ionic/angular';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CoreCourseIndexSectionWithModule } from '../course-index/course-index'; import { CoreCourseIndexSectionWithModule } from '../course-index/course-index';
import { CoreBlockHelper } from '@features/block/services/block-helper'; import { CoreBlockHelper } from '@features/block/services/block-helper';
@ -57,8 +58,10 @@ import { CoreSharedModule } from '@/core/shared.module';
import { CoreBlockComponentsModule } from '@features/block/components/components.module'; import { CoreBlockComponentsModule } from '@features/block/components/components.module';
import { CoreCourseComponentsModule } from '../components.module'; import { CoreCourseComponentsModule } from '../components.module';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { COURSE_ALL_SECTIONS_PREFERRED_PREFIX } from '@features/course/constants'; import { COURSE_ALL_SECTIONS_PREFERRED_PREFIX, COURSE_EXPANDED_SECTIONS_PREFIX } from '@features/course/constants';
import { toBoolean } from '@/core/transforms/boolean'; import { toBoolean } from '@/core/transforms/boolean';
import { CoreInfiniteLoadingComponent } from '@components/infinite-loading/infinite-loading';
import { CoreSite } from '@classes/sites/site';
/** /**
* Component to display course contents using a certain format. If the format isn't found, use default one. * Component to display course contents using a certain format. If the format isn't found, use default one.
@ -96,6 +99,10 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ViewChildren(CoreDynamicComponent) dynamicComponents?: QueryList<CoreDynamicComponent<any>>; @ViewChildren(CoreDynamicComponent) dynamicComponents?: QueryList<CoreDynamicComponent<any>>;
@ViewChild(CoreInfiniteLoadingComponent) infiteLoading?: CoreInfiniteLoadingComponent;
accordionMultipleValue: string[] = [];
// All the possible component classes. // All the possible component classes.
courseFormatComponent?: Type<unknown>; courseFormatComponent?: Type<unknown>;
singleSectionComponent?: Type<unknown>; singleSectionComponent?: Type<unknown>;
@ -119,9 +126,9 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
displayCourseIndex = false; displayCourseIndex = false;
displayBlocks = false; displayBlocks = false;
hasBlocks = false; hasBlocks = false;
selectedSection?: CoreCourseSection; selectedSection?: CoreCourseSectionToDisplay;
previousSection?: CoreCourseSection; previousSection?: CoreCourseSectionToDisplay;
nextSection?: CoreCourseSection; nextSection?: CoreCourseSectionToDisplay;
allSectionsId: number = CoreCourseProvider.ALL_SECTIONS_ID; allSectionsId: number = CoreCourseProvider.ALL_SECTIONS_ID;
stealthModulesSectionId: number = CoreCourseProvider.STEALTH_MODULES_SECTION_ID; stealthModulesSectionId: number = CoreCourseProvider.STEALTH_MODULES_SECTION_ID;
loaded = false; loaded = false;
@ -136,6 +143,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
protected modViewedObserver?: CoreEventObserver; protected modViewedObserver?: CoreEventObserver;
protected lastCourseFormat?: string; protected lastCourseFormat?: string;
protected viewedModulesInitialized = false; protected viewedModulesInitialized = false;
protected currentSite?: CoreSite;
constructor( constructor(
protected content: IonContent, protected content: IonContent,
@ -158,6 +166,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
return; return;
} }
this.currentSite = CoreSites.getRequiredCurrentSite();
// Listen for select course tab events to select the right section if needed. // Listen for select course tab events to select the right section if needed.
this.selectTabObserver = CoreEvents.on(CoreEvents.SELECT_COURSE_TAB, (data) => { this.selectTabObserver = CoreEvents.on(CoreEvents.SELECT_COURSE_TAB, (data) => {
if (data.name) { if (data.name) {
@ -196,10 +206,12 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
} }
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.initializeExpandedSections();
} }
/** /**
* Detect changes on input properties. * @inheritdoc
*/ */
async ngOnChanges(changes: { [name: string]: SimpleChange }): Promise<void> { async ngOnChanges(changes: { [name: string]: SimpleChange }): Promise<void> {
this.setInputData(); this.setInputData();
@ -287,14 +299,12 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
* Treat received sections. * Treat received sections.
* *
* @param sections Sections to treat. * @param sections Sections to treat.
* @returns Promise resolved when done.
*/ */
protected async treatSections(sections: CoreCourseSection[]): Promise<void> { protected async treatSections(sections: CoreCourseSectionToDisplay[]): Promise<void> {
const hasAllSections = sections[0].id == CoreCourseProvider.ALL_SECTIONS_ID; const hasAllSections = sections[0].id == CoreCourseProvider.ALL_SECTIONS_ID;
const hasSeveralSections = sections.length > 2 || (sections.length == 2 && !hasAllSections); const hasSeveralSections = sections.length > 2 || (sections.length == 2 && !hasAllSections);
await this.initializeViewedModules(); await this.initializeViewedModules();
if (this.selectedSection) { if (this.selectedSection) {
const selectedSection = this.selectedSection; const selectedSection = this.selectedSection;
// We have a selected section, but the list has changed. Search the section in the list. // We have a selected section, but the list has changed. Search the section in the list.
@ -366,14 +376,10 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
this.loaded = true; this.loaded = true;
this.sectionChanged(section, moduleId); this.sectionChanged(section, moduleId);
} }
return;
} }
/** /**
* Initialize viewed modules. * Initialize viewed modules.
*
* @returns Promise resolved when done.
*/ */
protected async initializeViewedModules(): Promise<void> { protected async initializeViewedModules(): Promise<void> {
if (this.viewedModulesInitialized) { if (this.viewedModulesInitialized) {
@ -387,6 +393,13 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
viewedModules.forEach(entry => { viewedModules.forEach(entry => {
this.viewedModules[entry.cmId] = true; this.viewedModules[entry.cmId] = true;
}); });
if (this.lastModuleViewed) {
const section = this.getViewedModuleSection(this.sections, this.lastModuleViewed);
if (section) {
this.setSectionExpanded(section);
}
}
} }
/** /**
@ -426,7 +439,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
// Check current scrolled section. // Check current scrolled section.
const allSectionElements: NodeListOf<HTMLElement> = const allSectionElements: NodeListOf<HTMLElement> =
this.elementRef.nativeElement.querySelectorAll('section.core-course-module-list-wrapper'); this.elementRef.nativeElement.querySelectorAll('.core-course-module-list-wrapper');
const scroll = await this.content.getScrollElement(); const scroll = await this.content.getScrollElement();
const containerTop = scroll.getBoundingClientRect().top; const containerTop = scroll.getBoundingClientRect().top;
@ -515,12 +528,15 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
* @param newSection The new selected section. * @param newSection The new selected section.
* @param moduleId The module to scroll to. * @param moduleId The module to scroll to.
*/ */
sectionChanged(newSection: CoreCourseSection, moduleId?: number): void { sectionChanged(newSection: CoreCourseSectionToDisplay, moduleId?: number): void {
const previousValue = this.selectedSection; const previousValue = this.selectedSection;
this.selectedSection = newSection; this.selectedSection = newSection;
this.data.section = this.selectedSection; this.data.section = this.selectedSection;
if (newSection.id !== this.allSectionsId) { if (newSection.id !== this.allSectionsId) {
this.setSectionExpanded(newSection);
// Select next and previous sections to show the arrows. // Select next and previous sections to show the arrows.
const i = this.sections.findIndex((value) => this.compareSections(value, newSection)); const i = this.sections.findIndex((value) => this.compareSections(value, newSection));
@ -627,7 +643,10 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
modulesLoaded < CoreCourseFormatComponent.LOAD_MORE_ACTIVITIES) { modulesLoaded < CoreCourseFormatComponent.LOAD_MORE_ACTIVITIES) {
this.lastShownSectionIndex++; this.lastShownSectionIndex++;
if (!this.sections[this.lastShownSectionIndex].hasContent || !this.sections[this.lastShownSectionIndex].modules) { // Skip sections without content, with stealth modules or collapsed.
if (!this.sections[this.lastShownSectionIndex].hasContent ||
!this.sections[this.lastShownSectionIndex].modules ||
!this.sections[this.lastShownSectionIndex].expanded) {
continue; continue;
} }
@ -712,10 +731,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
* *
* @param show Whether if all sections is preferred. * @param show Whether if all sections is preferred.
*/ */
async setAllSectionsPreferred(show: boolean): Promise<void> { protected async setAllSectionsPreferred(show: boolean): Promise<void> {
const site = CoreSites.getCurrentSite(); await this.currentSite?.setLocalSiteConfig(`${COURSE_ALL_SECTIONS_PREFERRED_PREFIX}${this.course.id}`, show ? 1 : 0);
await site?.setLocalSiteConfig(`${COURSE_ALL_SECTIONS_PREFERRED_PREFIX}${this.course.id}`, show ? 1 : 0);
} }
/** /**
@ -723,17 +740,88 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
* *
* @returns Whether if all sections is preferred. * @returns Whether if all sections is preferred.
*/ */
async isAllSectionsPreferred(): Promise<boolean> { protected async isAllSectionsPreferred(): Promise<boolean> {
const site = CoreSites.getCurrentSite();
const showAllSections = const showAllSections =
await site?.getLocalSiteConfig<number>(`${COURSE_ALL_SECTIONS_PREFERRED_PREFIX}${this.course.id}`, 0); await this.currentSite?.getLocalSiteConfig<number>(`${COURSE_ALL_SECTIONS_PREFERRED_PREFIX}${this.course.id}`, 0);
return !!showAllSections; return !!showAllSections;
} }
/**
* Save expanded sections for the course.
*/
protected async saveExpandedSections(): Promise<void> {
const expandedSections = this.sections.filter((section) => section.expanded).map((section) => section.id).join(',');
await this.currentSite?.setLocalSiteConfig(`${COURSE_EXPANDED_SECTIONS_PREFIX}${this.course.id}`, expandedSections);
}
/**
* Initializes the expanded sections for the course.
*/
protected async initializeExpandedSections(): Promise<void> {
const expandedSections = await CoreUtils.ignoreErrors(
this.currentSite?.getLocalSiteConfig<string>(`${COURSE_EXPANDED_SECTIONS_PREFIX}${this.course.id}`),
);
// Expand all sections if not defined.
if (expandedSections === undefined) {
this.sections.forEach((section) => {
section.expanded = true;
this.accordionMultipleValue.push(section.id.toString());
});
return;
}
this.accordionMultipleValue = expandedSections.split(',');
this.sections.forEach((section) => {
section.expanded = this.accordionMultipleValue.includes(section.id.toString());
});
}
/**
* Toogle the visibility of a section (expand/collapse).
*
* @param ev The event of the accordion.
*/
accordionMultipleChange(ev: AccordionGroupChangeEventDetail): void {
const sectionIds = ev.value as string[] | undefined;
this.sections.forEach((section) => {
section.expanded = false;
});
sectionIds?.forEach((sectionId) => {
const sId = Number(sectionId);
const section = this.sections.find((section) => section.id === sId);
if (section) {
section.expanded = true;
}
});
// Save course expanded sections.
this.saveExpandedSections();
this.infiteLoading?.fireInfiniteScrollIfNeeded();
}
/**
* Expands a section and save state.
*
* @param section The section to expand.
*/
protected setSectionExpanded(section: CoreCourseSectionToDisplay): void {
section.expanded = true;
if (!this.accordionMultipleValue.includes(section.id.toString())) {
this.accordionMultipleValue.push(section.id.toString());
this.saveExpandedSections();
}
}
} }
type CoreCourseSectionToDisplay = CoreCourseSection & { type CoreCourseSectionToDisplay = CoreCourseSection & {
highlighted?: boolean; highlighted?: boolean;
expanded?: boolean; // The aim of this property is to avoid DOM overloading.
}; };

View File

@ -18,3 +18,4 @@ export const CONTENTS_PAGE_NAME = 'contents';
export const COURSE_CONTENTS_PATH = `${COURSE_PAGE_NAME}/${COURSE_INDEX_PATH}/${CONTENTS_PAGE_NAME}`; export const COURSE_CONTENTS_PATH = `${COURSE_PAGE_NAME}/${COURSE_INDEX_PATH}/${CONTENTS_PAGE_NAME}`;
export const COURSE_ALL_SECTIONS_PREFERRED_PREFIX = 'CoreCourseFormatAllSectionsPreferred-'; export const COURSE_ALL_SECTIONS_PREFERRED_PREFIX = 'CoreCourseFormatAllSectionsPreferred-';
export const COURSE_EXPANDED_SECTIONS_PREFIX = 'CoreCourseFormatExpandedSections-';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB