Skip to content

Commit

Permalink
General: Allow to switch courses from the course icon (#8669)
Browse files Browse the repository at this point in the history
  • Loading branch information
az108 authored Jun 8, 2024
1 parent 5bb7194 commit 4c69e87
Show file tree
Hide file tree
Showing 11 changed files with 217 additions and 65 deletions.
18 changes: 11 additions & 7 deletions src/main/webapp/app/course/course-access-storage.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,29 @@ import { LocalStorageService } from 'ngx-webstorage';
providedIn: 'root',
})
export class CourseAccessStorageService {
private static readonly STORAGE_KEY = 'artemis.courseAccess';
public static readonly STORAGE_KEY = 'artemis.courseAccess';
public static readonly STORAGE_KEY_DROPDOWN = 'artemis.courseAccessDropdown';
public static readonly MAX_DISPLAYED_RECENTLY_ACCESSED_COURSES_OVERVIEW = 3;
// Maximum number of recently accessed courses displayed in the dropdown, including the current course. The current course will be removed before displaying the dropdown so only 6 - 1 courses will be displayed in the dropdown.
public static readonly MAX_DISPLAYED_RECENTLY_ACCESSED_COURSES_DROPDOWN = 6;

constructor(private localStorage: LocalStorageService) {}

onCourseAccessed(courseId: number): void {
const courseAccessMap: { [key: number]: number } = this.localStorage.retrieve(CourseAccessStorageService.STORAGE_KEY) || {};
onCourseAccessed(courseId: number, storageKey: string, maxAccessedCourses: number): void {
const courseAccessMap: { [key: number]: number } = this.localStorage.retrieve(storageKey) || {};

courseAccessMap[courseId] = Date.now();

if (Object.keys(courseAccessMap).length > 3) {
if (Object.keys(courseAccessMap).length > maxAccessedCourses) {
const oldestEntry = Object.entries(courseAccessMap).reduce((prev, curr) => (prev[1] < curr[1] ? prev : curr));
delete courseAccessMap[oldestEntry[0]];
}

this.localStorage.store(CourseAccessStorageService.STORAGE_KEY, courseAccessMap);
this.localStorage.store(storageKey, courseAccessMap);
}

getLastAccessedCourses(): number[] {
const courseAccessMap: { [key: number]: number } = this.localStorage.retrieve(CourseAccessStorageService.STORAGE_KEY) || {};
getLastAccessedCourses(storageKey: string): number[] {
const courseAccessMap: { [key: number]: number } = this.localStorage.retrieve(storageKey) || {};

return Object.entries(courseAccessMap)
.sort((a, b) => b[1] - a[1])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,16 @@ export class CourseManagementTabBarComponent implements OnInit, OnDestroy, After
});

// Notify the course access storage service that the course has been accessed
this.courseAccessStorageService.onCourseAccessed(courseId);
this.courseAccessStorageService.onCourseAccessed(
courseId,
CourseAccessStorageService.STORAGE_KEY,
CourseAccessStorageService.MAX_DISPLAYED_RECENTLY_ACCESSED_COURSES_OVERVIEW,
);
this.courseAccessStorageService.onCourseAccessed(
courseId,
CourseAccessStorageService.STORAGE_KEY_DROPDOWN,
CourseAccessStorageService.MAX_DISPLAYED_RECENTLY_ACCESSED_COURSES_DROPDOWN,
);
}

ngAfterViewInit() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export class CourseManagementComponent implements OnInit, OnDestroy, AfterViewIn
this.coursesBySemester = {};

// Get last accessed courses
const lastAccessedCourseIds = this.courseAccessStorageService.getLastAccessedCourses();
const lastAccessedCourseIds = this.courseAccessStorageService.getLastAccessedCourses(CourseAccessStorageService.STORAGE_KEY);
const recentlyAccessedCourses = this.courses.filter((course) => lastAccessedCourseIds.includes(course.id!));

let firstExpanded = false;
Expand Down
107 changes: 66 additions & 41 deletions src/main/webapp/app/overview/course-overview.component.html
Original file line number Diff line number Diff line change
@@ -1,55 +1,80 @@
<div class="sidebar-wrapper">
<div class="vw-100 bg-body">
<mat-sidenav-container class="vw-100" [ngClass]="{ 'sidenav-height-dev': !isProduction || isTestServer, 'container-closed': isNavbarCollapsed }">
<mat-sidenav
#sidenav
disableClose
[ngClass]="{ 'sidenav-height-dev': !isProduction || isTestServer }"
class="module-bg rounded-end rounded-3"
opened="true"
mode="side"
>
<mat-sidenav disableClose [ngClass]="{ 'sidenav-height-dev': !isProduction || isTestServer }" class="module-bg rounded-end rounded-3" opened="true" mode="side">
<div class="sidebar-container d-flex h-100 justify-content-between flex-column" [ngClass]="{ collapsed: isNavbarCollapsed }">
<!-- NavItems -->
<div>
<div class="d-flex p-3 align-items-center text-decoration-none" [title]="course?.title">
@if (course && course.courseIcon) {
<div>
<jhi-secured-image [src]="course.courseIcon" />
</div>
} @else {
<div class="course-circle d-flex align-items-center justify-content-center">
<span class="fs-4">{{ course?.title | slice: 0 : 1 }}</span>
<div id="container" class="d-flex p-3 align-items-center text-decoration-none" [title]="course?.title">
<div ngbDropdown container="body" class="d-flex">
@if (course && course.courseIcon) {
@if (courses?.length) {
<div ngbDropdownToggle class="d-flex align-items-center justify-content-center pointer">
<jhi-secured-image [src]="course.courseIcon" />
</div>
} @else {
<div class="d-flex align-items-center justify-content-center">
<jhi-secured-image [src]="course.courseIcon" />
</div>
}
} @else {
@if (courses?.length) {
<div ngbDropdownToggle class="course-circle d-flex align-items-center justify-content-center pointer">
<span class="fs-4">{{ course?.title | slice: 0 : 1 }}</span>
</div>
} @else {
<div class="course-circle d-flex align-items-center justify-content-center">
<span class="fs-4">{{ course?.title | slice: 0 : 1 }}</span>
</div>
}
}
<div ngbDropdownMenu class="dropdown-menu py-1 ms-n2">
@for (course of courses; track course) {
<button ngbDropdownItem (click)="switchCourse(course)" class="d-flex align-items-center py-1 px-2">
@if (course.courseIcon) {
<span class="d-flex align-items-center justify-content-center">
<jhi-secured-image [src]="course.courseIcon" />
</span>
} @else {
<div class="course-circle d-flex align-items-center justify-content-center">
<span class="fs-4">{{ course?.title | slice: 0 : 1 }}</span>
</div>
}
<div class="h6 fw-normal mb-0 course-title text-wrap">{{ course?.title }}</div>
</button>
}
</div>
}
</div>
@if (!isNavbarCollapsed) {
<div id="test-course-title" class="h6 mb-0 fw-bold text-body auto-collapse">{{ course?.title }}</div>
<div id="test-course-title" class="course-title h6 mb-0 fw-bold text-body auto-collapse">{{ course?.title }}</div>
}
</div>
<hr class="mt-0" />
<ul class="navbar-nav justify-content-end flex-grow-1 text-decoration-none">
@for (sidebarItem of sidebarItems; track sidebarItem) {
<li class="nav-item" [hidden]="sidebarItem.hidden">
@if (sidebarItem.hasInOrionProperty && sidebarItem.showInOrionWindow !== undefined) {
<ng-template
*ngTemplateOutlet="navItemOrionFilter; context: { $implicit: sidebarItem, iconTextTemplate: navIconAndText, extraPadding: false }"
/>
} @else {
<ng-template *ngTemplateOutlet="navItem; context: { $implicit: sidebarItem, iconTextTemplate: navIconAndText, extraPadding: false }" />
}
<div>
<hr class="mt-0" />
<ul class="navbar-nav justify-content-end flex-grow-1 text-decoration-none">
@for (sidebarItem of sidebarItems; track sidebarItem) {
<li class="nav-item" [hidden]="sidebarItem.hidden">
@if (sidebarItem.hasInOrionProperty && sidebarItem.showInOrionWindow !== undefined) {
<ng-template
*ngTemplateOutlet="navItemOrionFilter; context: { $implicit: sidebarItem, iconTextTemplate: navIconAndText, extraPadding: false }"
/>
} @else {
<ng-template *ngTemplateOutlet="navItem; context: { $implicit: sidebarItem, iconTextTemplate: navIconAndText, extraPadding: false }" />
}
</li>
}
<li class="nav-item">
<div [hidden]="!anyItemHidden" class="three-dots nav-link px-3" (click)="toggleDropdown()">
<fa-icon [fixedWidth]="true" [icon]="faEllipsis" class="ms-2 me-3" />
<span
class="more"
[ngClass]="{ 'auto-collapse': !isNavbarCollapsed, 'sidebar-collapsed': isNavbarCollapsed }"
[jhiTranslate]="'artemisApp.courseOverview.menu.more'"
></span>
</div>
</li>
}
<li class="nav-item">
<div [hidden]="!anyItemHidden" class="three-dots nav-link px-3" (click)="toggleDropdown()">
<fa-icon [fixedWidth]="true" [icon]="faEllipsis" class="ms-2 me-3" />
<span
class="more"
[ngClass]="{ 'auto-collapse': !isNavbarCollapsed, 'sidebar-collapsed': isNavbarCollapsed }"
[jhiTranslate]="'artemisApp.courseOverview.menu.more'"
></span>
</div>
</li>
</ul>
</ul>
</div>
</div>
<!-- Course Action Items -->
<div>
Expand Down
20 changes: 18 additions & 2 deletions src/main/webapp/app/overview/course-overview.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,6 @@ jhi-secured-image {
border-radius: 50%;
height: 36px;
width: auto;
margin-right: 0.75rem;
}
}

Expand Down Expand Up @@ -197,7 +196,11 @@ jhi-secured-image {
background-color: var(--course-image-bg);
border-radius: 50%;
display: inline-block;
margin-right: 0.75rem;
color: var(--bs-body-color);
}

.course-title {
margin-left: 0.75rem;
}

.max-width-collapsed {
Expand Down Expand Up @@ -253,3 +256,16 @@ jhi-secured-image {
max-height: 91px; // To avoid cut offs in the dropdown menu content
}
}

.dropdown-menu {
min-width: 204px;
max-width: 294px;
}

.dropdown-courses.active {
display: block;
}

.dropdown-toggle::after {
display: none;
}
48 changes: 42 additions & 6 deletions src/main/webapp/app/overview/course-overview.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ import { CourseExercisesComponent } from './course-exercises/course-exercises.co
import { CourseLecturesComponent } from './course-lectures/course-lectures.component';
import { facSidebar } from '../../content/icons/icons';
import { CourseTutorialGroupsComponent } from './course-tutorial-groups/course-tutorial-groups.component';
import { CoursesForDashboardDTO } from 'app/course/manage/courses-for-dashboard-dto';
import { sortCourses } from 'app/shared/util/course.util';

interface CourseActionItem {
title: string;
Expand Down Expand Up @@ -94,7 +96,9 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit

private courseId: number;
private subscription: Subscription;
dashboardSubscription: Subscription;
course?: Course;
courses?: Course[];
refreshingCourse = false;
private teamAssignmentUpdateListener: Subscription;
private quizExercisesChannel: string;
Expand All @@ -110,9 +114,9 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit
isNotManagementView: boolean;
canUnenroll: boolean;
isNavbarCollapsed = false;
isSidebarCollapsed = false;
profileSubscription?: Subscription;
showRefreshButton: boolean = false;
readonly MIN_DISPLAYED_COURSES: number = 6;

// Properties to track hidden items for dropdown menu
dropdownOpen: boolean = false;
Expand Down Expand Up @@ -162,11 +166,7 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit
faGraduationCap = faGraduationCap;
faSync = faSync;
faCircleNotch = faCircleNotch;
faNetworkWired = faNetworkWired;
faChalkboardUser = faChalkboardUser;
faChevronRight = faChevronRight;
faListCheck = faListCheck;
faDoorOpen = faDoorOpen;
facSidebar = facSidebar;
faEllipsis = faEllipsis;

Expand Down Expand Up @@ -203,9 +203,19 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit
});
this.getCollapseStateFromStorage();
this.course = this.courseStorageService.getCourse(this.courseId);
this.updateRecentlyAccessedCourses();
this.isNotManagementView = !this.router.url.startsWith('/course-management');
// Notify the course access storage service that the course has been accessed
this.courseAccessStorageService.onCourseAccessed(this.courseId);
this.courseAccessStorageService.onCourseAccessed(
this.courseId,
CourseAccessStorageService.STORAGE_KEY,
CourseAccessStorageService.MAX_DISPLAYED_RECENTLY_ACCESSED_COURSES_OVERVIEW,
);
this.courseAccessStorageService.onCourseAccessed(
this.courseId,
CourseAccessStorageService.STORAGE_KEY_DROPDOWN,
CourseAccessStorageService.MAX_DISPLAYED_RECENTLY_ACCESSED_COURSES_DROPDOWN,
);

await firstValueFrom(this.loadCourse());
await this.initAfterCourseLoad();
Expand Down Expand Up @@ -277,6 +287,31 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit
}
}

/** initialize courses attribute by retrieving recently accessed courses from the server */
updateRecentlyAccessedCourses() {
this.dashboardSubscription = this.courseService.findAllForDashboard().subscribe({
next: (res: HttpResponse<CoursesForDashboardDTO>) => {
if (res.body) {
const { courses: courseDtos } = res.body;
const courses = courseDtos.map((courseDto) => courseDto.course);
this.courses = sortCourses(courses);
if (this.courses.length > this.MIN_DISPLAYED_COURSES) {
const lastAccessedCourseIds = this.courseAccessStorageService.getLastAccessedCourses(CourseAccessStorageService.STORAGE_KEY_DROPDOWN);
this.courses = this.courses.filter((course) => lastAccessedCourseIds.includes(course.id!));
}
this.courses = this.courses.filter((course) => course.id !== this.courseId);
}
},
});
}

/** Navigate to a new Course */
switchCourse(course: Course) {
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
this.router.navigate(['courses', course.id]);
});
}

getCourseActionItems(): CourseActionItem[] {
const courseActionItems = [];
this.canUnenroll = this.canStudentUnenroll() && !this.course?.isAtLeastTutor;
Expand Down Expand Up @@ -708,6 +743,7 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit
this.vcSubscription?.unsubscribe();
this.subscription?.unsubscribe();
this.profileSubscription?.unsubscribe();
this.dashboardSubscription?.unsubscribe();
this.ngUnsubscribe.next();
this.ngUnsubscribe.complete();
}
Expand Down
5 changes: 3 additions & 2 deletions src/main/webapp/app/overview/courses.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Router } from '@angular/router';
import { faPenAlt } from '@fortawesome/free-solid-svg-icons';
import { CourseAccessStorageService } from 'app/course/course-access-storage.service';
import { CourseForDashboardDTO } from 'app/course/manage/course-for-dashboard-dto';
import { sortCourses } from 'app/shared/util/course.util';

@Component({
selector: 'jhi-overview',
Expand Down Expand Up @@ -71,7 +72,7 @@ export class CoursesComponent implements OnInit, OnDestroy {
res.body.courses.forEach((courseDto: CourseForDashboardDTO) => {
courses.push(courseDto.course);
});
this.courses = courses.sort((a, b) => (a.title ?? '').localeCompare(b.title ?? ''));
this.courses = sortCourses(courses);
this.courseForGuidedTour = this.guidedTourService.enableTourForCourseOverview(this.courses, courseOverviewTour, true);

this.nextRelevantExams = res.body.activeExams ?? [];
Expand All @@ -89,7 +90,7 @@ export class CoursesComponent implements OnInit, OnDestroy {
if (this.courses.length <= 5) {
this.regularCourses = this.courses;
} else {
const lastAccessedCourseIds = this.courseAccessStorageService.getLastAccessedCourses();
const lastAccessedCourseIds = this.courseAccessStorageService.getLastAccessedCourses(CourseAccessStorageService.STORAGE_KEY);
this.recentlyAccessedCourses = this.courses.filter((course) => lastAccessedCourseIds.includes(course.id!));
this.regularCourses = this.courses.filter((course) => !lastAccessedCourseIds.includes(course.id!));
}
Expand Down
11 changes: 11 additions & 0 deletions src/main/webapp/app/shared/util/course.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Course } from 'app/entities/course.model';

/**
* Sorts an array of Course objects alphabetically by their title.
*
* @param {Course[]} courses - The array of Course objects to be sorted.
* @returns {Course[]} The sorted array of Course objects.
*/
export function sortCourses(courses: Course[]): Course[] {
return courses.sort((courseA, courseB) => (courseA.title ?? '').localeCompare(courseB.title ?? ''));
}
Loading

0 comments on commit 4c69e87

Please sign in to comment.