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: {
course: this.course,
sections: this.sections,
subSections: this.subSections,
selectedId: selectedId,
},
});
@ -495,12 +496,25 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
if (!data) {
return;
}
const section = this.sections.find((section) => section.id === data.sectionId);
let section = this.sections.find((section) => section.id === data.sectionId);
if (!section) {
return;
}
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) {
return;
}
@ -515,7 +529,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
}
if (CoreCourseHelper.canUserViewModule(module, section)) {
this.scrollToModule(module.id);
this.scrollInCourse(module.id);
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.
const moduleIdToScroll = this.moduleId && previousValue === undefined ? this.moduleId : moduleId;
if (moduleIdToScroll) {
this.scrollToModule(moduleIdToScroll);
this.scrollInCourse(moduleIdToScroll);
}
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 {
CoreDom.scrollToElement(
this.elementRef.nativeElement,
'#core-course-module-' + moduleId,
{ addYAxis: -10 },
);
protected scrollInCourse(id: number, isSection = false): void {
const elementId = isSection ? `#core-section-name-${id}` : `#core-course-module-${id}`;
CoreDom.scrollToElement(this.elementRef.nativeElement, elementId,{ addYAxis: -10 });
}
/**

View File

@ -24,59 +24,66 @@
</ion-label>
</ion-item>
<ng-container *ngIf="allSectionId !== section.id">
<ion-item class="divider section" (click)="selectSectionOrModule($event, section.id)" button
[class.item-current]="selectedId === section.id" [class.item-dimmed]="!section.visible"
[class.item-hightlighted]="section.highlighted" [detail]="false">
<ion-icon *ngIf="section.hasVisibleModules" name="fas-chevron-right" flip-rtl slot="start"
class="expandable-status-icon" (ariaButtonClick)="toggleExpand($event, section)"
[attr.aria-label]="(section.expanded ? 'core.collapse' : 'core.expand') | translate"
[attr.aria-expanded]="section.expanded" [attr.aria-controls]="'core-course-index-section-' + section.id"
[class.expandable-status-icon-expanded]="section.expanded" />
<ion-icon *ngIf="!section.hasVisibleModules" name="" slot="start" aria-hidden="true"
class="expandable-status-icon" />
<ion-label>
<h2>
<core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course?.id" />
</h2>
</ion-label>
<ion-badge *ngIf="section.highlighted && highlighted" slot="end">{{highlighted}}</ion-badge>
<ion-icon name="fas-lock" *ngIf="section.availabilityinfo" slot="end" class="restricted"
[attr.aria-label]="'core.restricted' | translate" />
<ion-icon name="fas-eye-slash" *ngIf="!section.visible && !section.uservisible" slot="end" class="restricted"
[attr.aria-label]="'core.notavailable' | translate" />
<ion-icon name="fas-eye-slash" *ngIf="!section.visible && section.uservisible" slot="end" class="restricted"
[attr.aria-label]="'core.course.hiddenfromstudents' | translate" />
</ion-item>
<div id="core-course-index-section-{{section.id}}">
<ng-container *ngIf="section.expanded">
<ng-container *ngFor="let module of section.modules">
<ion-item class="module" [class.item-dimmed]="!module.visible" [class.indented]="module.indented"
[class.item-hightlighted]="section.highlighted"
(click)="selectSectionOrModule($event, section.id, module.id)" button>
<ion-icon class="completioninfo completion_none" name="" *ngIf="module.completionStatus === undefined"
slot="start" aria-hidden="true" />
<ion-icon class="completioninfo completion_incomplete" name="far-circle"
*ngIf="module.completionStatus === 0" slot="start"
[attr.aria-label]="'core.course.todo' | translate" />
<ion-icon class="completioninfo completion_complete" name="fas-circle"
*ngIf="module.completionStatus === 1 || module.completionStatus === 2" color="success" slot="start"
[attr.aria-label]="'core.course.done' | translate" />
<ion-icon class="completioninfo completion_fail" name="fas-xmark" *ngIf="module.completionStatus === 3"
color="danger" slot="start" [attr.aria-label]="'core.course.failed' | translate" />
<ion-label>
<p class="item-heading">
<core-format-text [text]="module.name" contextLevel="module" [contextInstanceId]="module.id"
[courseId]="module.course" />
</p>
</ion-label>
<ion-icon name="fas-lock" *ngIf="!module.uservisible" slot="end" class="restricted"
[attr.aria-label]="'core.restricted' | translate" />
</ion-item>
</ng-container>
</ng-container>
</div>
<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
[class.item-current]="selectedId === section.id" [class.item-dimmed]="!section.visible"
[class.item-hightlighted]="section.highlighted" [detail]="false">
<ion-icon *ngIf="section.hasVisibleModules" name="fas-chevron-right" flip-rtl slot="start" class="expandable-status-icon"
(ariaButtonClick)="toggleExpand($event, section)"
[attr.aria-label]="(section.expanded ? 'core.collapse' : 'core.expand') | translate" [attr.aria-expanded]="section.expanded"
[attr.aria-controls]="'core-course-index-section-' + section.id" [class.expandable-status-icon-expanded]="section.expanded" />
<ion-icon *ngIf="!section.hasVisibleModules" name="" slot="start" aria-hidden="true" class="expandable-status-icon" />
<ion-label>
<h2>
<core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course?.id" />
</h2>
</ion-label>
<ion-badge *ngIf="section.highlighted && highlighted" slot="end">{{highlighted}}</ion-badge>
<ion-icon name="fas-lock" *ngIf="section.availabilityinfo" slot="end" class="restricted"
[attr.aria-label]="'core.restricted' | translate" />
<ion-icon name="fas-eye-slash" *ngIf="!section.visible && !section.uservisible" slot="end" class="restricted"
[attr.aria-label]="'core.notavailable' | translate" />
<ion-icon name="fas-eye-slash" *ngIf="!section.visible && section.uservisible" slot="end" class="restricted"
[attr.aria-label]="'core.course.hiddenfromstudents' | translate" />
</ion-item>
<div id="core-course-index-section-{{section.id}}" class="core-course-index-section-content">
<ng-container *ngIf="section.expanded">
<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"
[class.item-hightlighted]="section.highlighted" (click)="selectSectionOrModule($event, section.id, module.id)" button>
<ion-icon class="completioninfo completion_none" name="" *ngIf="module.completionStatus === undefined" slot="start"
aria-hidden="true" />
<ion-icon class="completioninfo completion_incomplete" name="far-circle" *ngIf="module.completionStatus === 0"
slot="start" [attr.aria-label]="'core.course.todo' | translate" />
<ion-icon class="completioninfo completion_complete" name="fas-circle"
*ngIf="module.completionStatus === 1 || module.completionStatus === 2" color="success" slot="start"
[attr.aria-label]="'core.course.done' | translate" />
<ion-icon class="completioninfo completion_fail" name="fas-xmark" *ngIf="module.completionStatus === 3" color="danger"
slot="start" [attr.aria-label]="'core.course.failed' | translate" />
<ion-label>
<p class="item-heading">
<core-format-text [text]="module.name" contextLevel="module" [contextInstanceId]="module.id"
[courseId]="module.course" />
</p>
</ion-label>
<ion-icon name="fas-lock" *ngIf="!module.uservisible" slot="end" class="restricted"
[attr.aria-label]="'core.restricted' | translate" />
</ion-item>
}
</ng-container>
</ng-container>
</div>
</ng-template>

View File

@ -1,6 +1,6 @@
@use "theme/globals" as *;
core-progress-bar {
--bar-margin: 8px 0 4px 0;
--bar-margin: 8px 0px 4px 0px;
--line-height: 20px;
--background: var(--contrast-background);
}
@ -19,7 +19,7 @@ ion-item.item {
&.item-current {
--background: var(--primary-tint);
--color: var(--gray-900);
border: 0;
border: 0px;
}
&.item-hightlighted {
@ -56,7 +56,7 @@ ion-item.item {
&.module {
&::part(native) {
--padding-start: 0;
--padding-start: 0px;
}
&.item-hightlighted ion-icon.completioninfo {
@ -70,7 +70,7 @@ ion-item.item {
}
ion-icon {
margin: 0;
margin: 0px;
padding: 12px 16px;
&.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,
} from '@features/course/services/course';
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 { CoreCoursesHelper } from '@features/courses/services/courses-helper';
import { CoreSites } from '@services/sites';
@ -43,6 +43,7 @@ import { CoreDom } from '@singletons/dom';
export class CoreCourseCourseIndexComponent implements OnInit {
@Input() sections: CoreCourseSection[] = [];
@Input() subSections: CoreCourseSection[] = [];
@Input() selectedId?: number;
@Input() course?: CoreCourseAnyCourseData;
@ -87,38 +88,8 @@ export class CoreCourseCourseIndexComponent implements OnInit {
const enableIndentation = await CoreCourse.isCourseIndentationEnabled(site, this.course.id);
this.sectionsToRender = this.sections
.filter((section) => !CoreCourseHelper.isSectionStealth(section))
.map((section) => {
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,
};
});
.filter((section) => section.component !== 'mod_subsection' && !CoreCourseHelper.isSectionStealth(section))
.map((section) => this.mapSectionToRender(section, completionEnabled, enableIndentation, currentSectionData));
this.highlighted = CoreCourseFormatDelegate.getSectionHightlightedName(this.course);
@ -163,7 +134,26 @@ export class CoreCourseCourseIndexComponent implements OnInit {
* @param moduleId Selected module id, if any.
*/
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;
}
/**
* 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 = {
@ -205,11 +258,13 @@ type CourseIndexSection = {
indented: boolean;
uservisible: boolean;
completionStatus?: CoreCourseModuleCompletionStatus;
subSection?: CourseIndexSection;
}[];
};
export type CoreCourseIndexSectionWithModule = {
event: Event;
sectionId: number;
subSectionId?: 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">
<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-label class="ion-text-wrap">
<h2 *ngIf="section.name" class="big" [id]="'core-section-name-' + section.id">
@ -27,62 +44,9 @@
</ion-label>
<ion-badge *ngIf="section.highlighted && highlightedName" slot="end">{{highlightedName}}</ion-badge>
</ion-item>
</ng-template>
<div slot="content">
<ng-container *ngIf="section.expanded">
<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>
</ng-container>
</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>
<ng-template #sectionContent>
<ion-item class="ion-text-wrap section-summary" *ngIf="section.summary">
<ion-label>
<core-format-text [text]="section.summary" contextLevel="course" [contextInstanceId]="course.id" />
@ -101,4 +65,4 @@
(!module.completiondata || module.completiondata.state === completionStatusIncomplete)" />
}
</ng-container>
</div>
</ng-template>