MOBILE-4442 course: Manage subsections on course index page

main
Pau Ferrer Ocaña 2024-09-18 14:53:25 +02:00
parent aad989982d
commit cb9580b73e
5 changed files with 199 additions and 157 deletions

View File

@ -488,6 +488,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
componentProps: { componentProps: {
course: this.course, course: this.course,
sections: this.sections, sections: this.sections,
subSections: this.subSections,
selectedId: selectedId, selectedId: selectedId,
}, },
}); });
@ -495,12 +496,25 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
if (!data) { if (!data) {
return; return;
} }
const section = this.sections.find((section) => section.id === data.sectionId); let section = this.sections.find((section) => section.id === data.sectionId);
if (!section) { if (!section) {
return; return;
} }
this.sectionChanged(section); this.sectionChanged(section);
if (data.subSectionId) {
section = this.subSections.find((section) => section.id === data.subSectionId);
if (!section) {
return;
}
// Use this section to find the module.
this.setSectionExpanded(section);
// Scroll to the subsection (later it may be scrolled to the module).
this.scrollInCourse(section.id, true);
}
if (!data.moduleId) { if (!data.moduleId) {
return; return;
} }
@ -515,7 +529,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
} }
if (CoreCourseHelper.canUserViewModule(module, section)) { if (CoreCourseHelper.canUserViewModule(module, section)) {
this.scrollToModule(module.id); this.scrollInCourse(module.id);
module.handlerData?.action?.(data.event, module, module.course); module.handlerData?.action?.(data.event, module, module.course);
} }
@ -585,7 +599,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
// Scroll to module if needed. Give more priority to the input. // Scroll to module if needed. Give more priority to the input.
const moduleIdToScroll = this.moduleId && previousValue === undefined ? this.moduleId : moduleId; const moduleIdToScroll = this.moduleId && previousValue === undefined ? this.moduleId : moduleId;
if (moduleIdToScroll) { if (moduleIdToScroll) {
this.scrollToModule(moduleIdToScroll); this.scrollInCourse(moduleIdToScroll);
} }
if (!previousValue || previousValue.id !== newSection.id) { if (!previousValue || previousValue.id !== newSection.id) {
@ -600,16 +614,14 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
} }
/** /**
* Scroll to a certain module. * Scroll to a certain module or section.
* *
* @param moduleId Module ID. * @param id ID of the module or section to scroll to.
* @param isSection Whether to scroll to a module or a subsection.
*/ */
protected scrollToModule(moduleId: number): void { protected scrollInCourse(id: number, isSection = false): void {
CoreDom.scrollToElement( const elementId = isSection ? `#core-section-name-${id}` : `#core-course-module-${id}`;
this.elementRef.nativeElement, CoreDom.scrollToElement(this.elementRef.nativeElement, elementId,{ addYAxis: -10 });
'#core-course-module-' + moduleId,
{ addYAxis: -10 },
);
} }
/** /**

View File

@ -24,16 +24,23 @@
</ion-label> </ion-label>
</ion-item> </ion-item>
<ng-container *ngIf="allSectionId !== section.id"> <ng-container *ngIf="allSectionId !== section.id">
<ng-container *ngTemplateOutlet="sectionTemplate; context: {section}" />
</ng-container>
</ng-container>
</ion-list>
</core-loading>
</ion-content>
<ng-template #sectionTemplate let-section="section">
<ion-item class="divider section" (click)="selectSectionOrModule($event, section.id)" button <ion-item class="divider section" (click)="selectSectionOrModule($event, section.id)" button
[class.item-current]="selectedId === section.id" [class.item-dimmed]="!section.visible" [class.item-current]="selectedId === section.id" [class.item-dimmed]="!section.visible"
[class.item-hightlighted]="section.highlighted" [detail]="false"> [class.item-hightlighted]="section.highlighted" [detail]="false">
<ion-icon *ngIf="section.hasVisibleModules" name="fas-chevron-right" flip-rtl slot="start" <ion-icon *ngIf="section.hasVisibleModules" name="fas-chevron-right" flip-rtl slot="start" class="expandable-status-icon"
class="expandable-status-icon" (ariaButtonClick)="toggleExpand($event, section)" (ariaButtonClick)="toggleExpand($event, section)"
[attr.aria-label]="(section.expanded ? 'core.collapse' : 'core.expand') | translate" [attr.aria-label]="(section.expanded ? 'core.collapse' : 'core.expand') | translate" [attr.aria-expanded]="section.expanded"
[attr.aria-expanded]="section.expanded" [attr.aria-controls]="'core-course-index-section-' + section.id" [attr.aria-controls]="'core-course-index-section-' + section.id" [class.expandable-status-icon-expanded]="section.expanded" />
[class.expandable-status-icon-expanded]="section.expanded" /> <ion-icon *ngIf="!section.hasVisibleModules" name="" slot="start" aria-hidden="true" class="expandable-status-icon" />
<ion-icon *ngIf="!section.hasVisibleModules" name="" slot="start" aria-hidden="true"
class="expandable-status-icon" />
<ion-label> <ion-label>
<h2> <h2>
<core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course?.id" /> <core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course?.id" />
@ -47,22 +54,25 @@
<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" /> [attr.aria-label]="'core.course.hiddenfromstudents' | translate" />
</ion-item> </ion-item>
<div id="core-course-index-section-{{section.id}}"> <div id="core-course-index-section-{{section.id}}" class="core-course-index-section-content">
<ng-container *ngIf="section.expanded"> <ng-container *ngIf="section.expanded">
<ng-container *ngFor="let module of section.modules"> <ng-container *ngFor="let module of section.modules">
@if (module.subSection) {
<div class="core-course-index-subsection">
<ng-container *ngTemplateOutlet="sectionTemplate; context: {section: module.subSection}" />
</div>
} @else {
<ion-item class="module" [class.item-dimmed]="!module.visible" [class.indented]="module.indented" <ion-item class="module" [class.item-dimmed]="!module.visible" [class.indented]="module.indented"
[class.item-hightlighted]="section.highlighted" [class.item-hightlighted]="section.highlighted" (click)="selectSectionOrModule($event, section.id, module.id)" button>
(click)="selectSectionOrModule($event, section.id, module.id)" button> <ion-icon class="completioninfo completion_none" name="" *ngIf="module.completionStatus === undefined" slot="start"
<ion-icon class="completioninfo completion_none" name="" *ngIf="module.completionStatus === undefined" aria-hidden="true" />
slot="start" aria-hidden="true" /> <ion-icon class="completioninfo completion_incomplete" name="far-circle" *ngIf="module.completionStatus === 0"
<ion-icon class="completioninfo completion_incomplete" name="far-circle" slot="start" [attr.aria-label]="'core.course.todo' | translate" />
*ngIf="module.completionStatus === 0" slot="start"
[attr.aria-label]="'core.course.todo' | translate" />
<ion-icon class="completioninfo completion_complete" name="fas-circle" <ion-icon class="completioninfo completion_complete" name="fas-circle"
*ngIf="module.completionStatus === 1 || module.completionStatus === 2" color="success" slot="start" *ngIf="module.completionStatus === 1 || module.completionStatus === 2" color="success" slot="start"
[attr.aria-label]="'core.course.done' | translate" /> [attr.aria-label]="'core.course.done' | translate" />
<ion-icon class="completioninfo completion_fail" name="fas-xmark" *ngIf="module.completionStatus === 3" <ion-icon class="completioninfo completion_fail" name="fas-xmark" *ngIf="module.completionStatus === 3" color="danger"
color="danger" slot="start" [attr.aria-label]="'core.course.failed' | translate" /> slot="start" [attr.aria-label]="'core.course.failed' | translate" />
<ion-label> <ion-label>
<p class="item-heading"> <p class="item-heading">
<core-format-text [text]="module.name" contextLevel="module" [contextInstanceId]="module.id" <core-format-text [text]="module.name" contextLevel="module" [contextInstanceId]="module.id"
@ -72,11 +82,8 @@
<ion-icon name="fas-lock" *ngIf="!module.uservisible" slot="end" class="restricted" <ion-icon name="fas-lock" *ngIf="!module.uservisible" slot="end" class="restricted"
[attr.aria-label]="'core.restricted' | translate" /> [attr.aria-label]="'core.restricted' | translate" />
</ion-item> </ion-item>
}
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
</ng-container> </ng-template>
</ng-container>
</ion-list>
</core-loading>
</ion-content>

View File

@ -1,6 +1,6 @@
@use "theme/globals" as *; @use "theme/globals" as *;
core-progress-bar { core-progress-bar {
--bar-margin: 8px 0 4px 0; --bar-margin: 8px 0px 4px 0px;
--line-height: 20px; --line-height: 20px;
--background: var(--contrast-background); --background: var(--contrast-background);
} }
@ -19,7 +19,7 @@ ion-item.item {
&.item-current { &.item-current {
--background: var(--primary-tint); --background: var(--primary-tint);
--color: var(--gray-900); --color: var(--gray-900);
border: 0; border: 0px;
} }
&.item-hightlighted { &.item-hightlighted {
@ -56,7 +56,7 @@ ion-item.item {
&.module { &.module {
&::part(native) { &::part(native) {
--padding-start: 0; --padding-start: 0px;
} }
&.item-hightlighted ion-icon.completioninfo { &.item-hightlighted ion-icon.completioninfo {
@ -70,7 +70,7 @@ ion-item.item {
} }
ion-icon { ion-icon {
margin: 0; margin: 0px;
padding: 12px 16px; padding: 12px 16px;
&.completioninfo { &.completioninfo {
@ -87,3 +87,7 @@ ion-item.item {
} }
} }
} }
div.core-course-index-subsection {
@include padding-horizontal(16px, null);
}

View File

@ -20,7 +20,7 @@ import {
CoreCourseProvider, CoreCourseProvider,
} from '@features/course/services/course'; } from '@features/course/services/course';
import { CoreCourseHelper, CoreCourseModuleData, CoreCourseSection } from '@features/course/services/course-helper'; import { CoreCourseHelper, CoreCourseModuleData, CoreCourseSection } from '@features/course/services/course-helper';
import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate'; import { CoreCourseFormatCurrentSectionData, CoreCourseFormatDelegate } from '@features/course/services/format-delegate';
import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; import { CoreCourseAnyCourseData } from '@features/courses/services/courses';
import { CoreCoursesHelper } from '@features/courses/services/courses-helper'; import { CoreCoursesHelper } from '@features/courses/services/courses-helper';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
@ -43,6 +43,7 @@ import { CoreDom } from '@singletons/dom';
export class CoreCourseCourseIndexComponent implements OnInit { export class CoreCourseCourseIndexComponent implements OnInit {
@Input() sections: CoreCourseSection[] = []; @Input() sections: CoreCourseSection[] = [];
@Input() subSections: CoreCourseSection[] = [];
@Input() selectedId?: number; @Input() selectedId?: number;
@Input() course?: CoreCourseAnyCourseData; @Input() course?: CoreCourseAnyCourseData;
@ -87,38 +88,8 @@ export class CoreCourseCourseIndexComponent implements OnInit {
const enableIndentation = await CoreCourse.isCourseIndentationEnabled(site, this.course.id); const enableIndentation = await CoreCourse.isCourseIndentationEnabled(site, this.course.id);
this.sectionsToRender = this.sections this.sectionsToRender = this.sections
.filter((section) => !CoreCourseHelper.isSectionStealth(section)) .filter((section) => section.component !== 'mod_subsection' && !CoreCourseHelper.isSectionStealth(section))
.map((section) => { .map((section) => this.mapSectionToRender(section, completionEnabled, enableIndentation, currentSectionData));
const modules = section.modules
.filter((module) => this.renderModule(section, module))
.map((module) => {
const completionStatus = completionEnabled
? CoreCourseHelper.getCompletionStatus(module.completiondata)
: undefined;
return {
id: module.id,
name: module.name,
course: module.course,
visible: !!module.visible,
uservisible: CoreCourseHelper.canUserViewModule(module, section),
indented: enableIndentation && module.indent > 0,
completionStatus,
};
});
return {
id: section.id,
name: section.name,
availabilityinfo: !!section.availabilityinfo,
visible: !!section.visible,
uservisible: CoreCourseHelper.canUserViewSection(section),
expanded: section.id === this.selectedId,
highlighted: currentSectionData.section.id === section.id,
hasVisibleModules: modules.length > 0,
modules: modules,
};
});
this.highlighted = CoreCourseFormatDelegate.getSectionHightlightedName(this.course); this.highlighted = CoreCourseFormatDelegate.getSectionHightlightedName(this.course);
@ -163,7 +134,26 @@ export class CoreCourseCourseIndexComponent implements OnInit {
* @param moduleId Selected module id, if any. * @param moduleId Selected module id, if any.
*/ */
selectSectionOrModule(event: Event, sectionId: number, moduleId?: number): void { selectSectionOrModule(event: Event, sectionId: number, moduleId?: number): void {
ModalController.dismiss({ event, sectionId, moduleId }); let subSectionId: number | undefined;
this.sectionsToRender.some((section) => {
if (section.id === sectionId) {
return true;
}
return section.modules.some((module) => {
if (module.subSection?.id === sectionId) {
// Always use the parent section.
subSectionId = sectionId;
sectionId = section.id;
return true;
}
return false;
});
});
ModalController.dismiss({ event, sectionId, subSectionId, moduleId });
} }
/** /**
@ -187,6 +177,69 @@ export class CoreCourseCourseIndexComponent implements OnInit {
return !module.noviewlink; return !module.noviewlink;
} }
/**
* Map a section to the format needed to render it.
*
* @param section Section to map.
* @param completionEnabled Whether completion is enabled.
* @param enableIndentation Whether indentation is enabled.
* @param currentSectionData Current section data.
* @returns Mapped section.
*/
protected mapSectionToRender(
section: CoreCourseSection,
completionEnabled: boolean,
enableIndentation: boolean,
currentSectionData?: CoreCourseFormatCurrentSectionData<CoreCourseSection>,
): CourseIndexSection {
const modules = section.modules
.filter((module) => module.modname === 'subsection' || this.renderModule(section, module))
.map((module) => {
if (module.modname === 'subsection') {
const subSectionFound = this.subSections.find((subSection) => subSection.itemid === module.instance);
const subSection = subSectionFound
? this.mapSectionToRender(subSectionFound, completionEnabled, enableIndentation)
: undefined;
return {
id: module.id,
name: module.name,
course: module.course,
visible: !!module.visible,
uservisible: CoreCourseHelper.canUserViewModule(module, section),
indented: true,
subSection,
};
}
const completionStatus = completionEnabled
? CoreCourseHelper.getCompletionStatus(module.completiondata)
: undefined;
return {
id: module.id,
name: module.name,
course: module.course,
visible: !!module.visible,
uservisible: CoreCourseHelper.canUserViewModule(module, section),
indented: enableIndentation && module.indent > 0,
completionStatus,
};
});
return {
id: section.id,
name: section.name,
availabilityinfo: !!section.availabilityinfo,
visible: !!section.visible,
uservisible: CoreCourseHelper.canUserViewSection(section),
expanded: section.id === this.selectedId,
highlighted: currentSectionData?.section.id === section.id,
hasVisibleModules: modules.length > 0,
modules,
};
}
} }
type CourseIndexSection = { type CourseIndexSection = {
@ -205,11 +258,13 @@ type CourseIndexSection = {
indented: boolean; indented: boolean;
uservisible: boolean; uservisible: boolean;
completionStatus?: CoreCourseModuleCompletionStatus; completionStatus?: CoreCourseModuleCompletionStatus;
subSection?: CourseIndexSection;
}[]; }[];
}; };
export type CoreCourseIndexSectionWithModule = { export type CoreCourseIndexSectionWithModule = {
event: Event; event: Event;
sectionId: number; sectionId: number;
subSectionId?: number;
moduleId?: number; moduleId?: number;
}; };

View File

@ -1,6 +1,23 @@
<ion-accordion *ngIf="collapsible" class="core-course-module-list-wrapper" [id]="section.id" @if (collapsible) {
<ion-accordion class="core-course-module-list-wrapper" [id]="section.id"
[attr.aria-labelledby]="section.name ? 'core-section-name-' + section.id : null" [value]="section.id" toggleIconSlot="start"> [attr.aria-labelledby]="section.name ? 'core-section-name-' + section.id : null" [value]="section.id" toggleIconSlot="start">
<ng-container *ngTemplateOutlet="sectionHeader" />
<div slot="content">
<ng-container *ngIf="section.expanded">
<ng-container *ngTemplateOutlet="sectionContent" />
</ng-container>
</div>
</ion-accordion>
} @else {
<div class="core-course-module-list-wrapper" [id]="section.id"
[attr.aria-labelledby]="section.name ? 'core-section-name-' + section.id : null">
<ng-container *ngTemplateOutlet="sectionHeader" />
<ng-container *ngTemplateOutlet="sectionContent" />
</div>
}
<ng-template #sectionHeader>
<ion-item class="course-section divider" [class.item-dimmed]="section.visible === 0 || section.uservisible === false" slot="header"> <ion-item class="course-section divider" [class.item-dimmed]="section.visible === 0 || section.uservisible === false" slot="header">
<ion-label class="ion-text-wrap"> <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">
@ -27,9 +44,9 @@
</ion-label> </ion-label>
<ion-badge *ngIf="section.highlighted && highlightedName" slot="end">{{highlightedName}}</ion-badge> <ion-badge *ngIf="section.highlighted && highlightedName" slot="end">{{highlightedName}}</ion-badge>
</ion-item> </ion-item>
</ng-template>
<div slot="content"> <ng-template #sectionContent>
<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" />
@ -48,57 +65,4 @@
(!module.completiondata || module.completiondata.state === completionStatusIncomplete)" /> (!module.completiondata || module.completiondata.state === completionStatusIncomplete)" />
} }
</ng-container> </ng-container>
</ng-container> </ng-template>
</div>
</ion-accordion>
<div *ngIf="!collapsible" class="core-course-module-list-wrapper" [id]="section.id"
[attr.aria-labelledby]="section.name ? 'core-section-name-' + section.id : null">
<ion-item class="course-section divider" [class.item-dimmed]="section.visible === 0 || section.uservisible === false">
<ion-label class="ion-text-wrap">
<h2 *ngIf="section.name" class="big" [id]="'core-section-name-' + section.id">
<core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course.id" />
</h2>
<div *ngIf="section.visible === 0 && section.uservisible !== false">
<ion-badge color="warning">
{{ 'core.course.hiddenfromstudents' | translate }}
</ion-badge>
</div>
<div *ngIf="section.visible === 0 && section.uservisible === false">
<ion-badge color="warning">
{{ 'core.notavailable' | translate }}
</ion-badge>
</div>
<div *ngIf="section.availabilityinfo">
<ion-chip class="clickable">
<ion-icon name="fas-lock" [attr.aria-label]="'core.restricted' | translate" />
<ion-label>
<core-format-text [text]=" section.availabilityinfo" contextLevel="course" [contextInstanceId]="course.id" />
</ion-label>
</ion-chip>
</div>
</ion-label>
<ion-badge *ngIf="section.highlighted && highlightedName" slot="end">{{highlightedName}}</ion-badge>
</ion-item>
<ion-item class="ion-text-wrap section-summary" *ngIf="section.summary">
<ion-label>
<core-format-text [text]="section.summary" contextLevel="course" [contextInstanceId]="course.id" />
</ion-label>
</ion-item>
<ng-container *ngFor="let module of modules">
@if (module.subsection) {
<core-course-section [course]="course" [section]="module.subsection" [lastModuleViewed]="lastModuleViewed"
[viewedModules]="viewedModules" [collapsible]="true" />
} @else {
<core-course-module *ngIf="module.visibleoncoursepage !== 0" [module]="module" [section]="section"
[showActivityDates]="course.showactivitydates" [showCompletionConditions]="course.showcompletionconditions"
[isLastViewed]="lastModuleViewed && lastModuleViewed.cmId === module.id" [class.core-course-module-not-viewed]="
!viewedModules[module.id] &&
(!module.completiondata || module.completiondata.state === completionStatusIncomplete)" />
}
</ng-container>
</div>