- @if (course && course.courseIcon) {
+ @if (course) {
@if (courses?.length) {
-
-
+
+
} @else {
-
-
-
- }
- } @else {
- @if (courses?.length) {
-
- {{ course?.title | slice: 0 : 1 }}
-
- } @else {
-
- {{ course?.title | slice: 0 : 1 }}
-
+
}
}
+
+
-
+
@for (sidebarItem of sidebarItems; track sidebarItem) {
-
@if (sidebarItem.hasInOrionProperty && sidebarItem.showInOrionWindow !== undefined) {
-
+
} @else {
-
+
}
}
- -
-
+
-
+
+
= 4 }">
+ @for (hiddenItem of hiddenItems; track hiddenItem) {
+
-
+ @if (hiddenItem.hasInOrionProperty && hiddenItem.showInOrionWindow !== undefined) {
+
+ } @else {
+
+ }
+
+ }
+ @if (courseActionItems?.length && anyItemHidden) {
+ @for (courseActionItem of courseActionItems; track courseActionItem) {
+ -
+
+
+ }
+ }
+
@@ -132,34 +137,6 @@
'exam-end-view': isExamEndView,
}"
>
-
= 3 }">
- @for (hiddenItem of hiddenItems; track hiddenItem) {
-
- @if (hiddenItem.hasInOrionProperty && hiddenItem.showInOrionWindow !== undefined) {
-
- } @else {
-
- }
-
- }
- @if (courseActionItems?.length) {
- @for (courseActionItem of courseActionItems; track courseActionItem) {
-
-
-
- }
- }
-
-
@if (course) {
+
+
+
+ @if (courseImage) {
+
+
+
+ } @else {
+
+ {{ courseTitle | slice: 0 : 1 }}
+
+ }
+
+
@if (sidebarItem.icon) {
@@ -233,15 +224,14 @@
}
-
+
-
+
diff --git a/src/main/webapp/app/overview/course-overview.component.scss b/src/main/webapp/app/overview/course-overview.component.scss
index a3547edf834a..5763644ae5cf 100644
--- a/src/main/webapp/app/overview/course-overview.component.scss
+++ b/src/main/webapp/app/overview/course-overview.component.scss
@@ -189,10 +189,12 @@ a:not(.btn):not(.tab-link):hover {
font-size: xx-small;
}
-.newMessage:after {
+.newMessage:after,
+.dropdown-content > .nav-item > .newMessage:after {
@extend %message-block;
margin-left: 0.25rem;
}
+
.collapsed.newMessage:after {
@extend %message-block;
margin-left: -0.9rem;
@@ -287,11 +289,10 @@ jhi-secured-image {
position: absolute;
background-color: var(--dropdown-bg);
border: 1px solid var(--border-color);
- z-index: 1000000;
+ z-index: 3000;
border-radius: 4px;
&.fixedContentSize {
- bottom: 0px;
- max-height: 91px; // To avoid cut offs in the dropdown menu content
+ max-height: 171px; // To avoid cut offs in the dropdown menu content (4 items)
}
}
diff --git a/src/main/webapp/app/overview/course-overview.component.ts b/src/main/webapp/app/overview/course-overview.component.ts
index 6b591d33de80..f540edc0fbf7 100644
--- a/src/main/webapp/app/overview/course-overview.component.ts
+++ b/src/main/webapp/app/overview/course-overview.component.ts
@@ -36,7 +36,7 @@ import {
faTimes,
faWrench,
} from '@fortawesome/free-solid-svg-icons';
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
+import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { AlertService, AlertType } from 'app/core/util/alert.service';
import { JhiWebsocketService } from 'app/core/websocket/websocket.service';
import { CourseAccessStorageService } from 'app/course/course-access-storage.service';
@@ -157,6 +157,8 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit
// Using a list query to be able to listen for changes (late mount); need both as this only returns native nodes
@ViewChildren('controlsViewContainer') controlsViewContainerAsList: QueryList;
+ @ViewChild('itemsDrop', { static: true }) itemsDrop: NgbDropdown;
+
// Icons
faTimes = faTimes;
faEye = faEye;
@@ -229,71 +231,42 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit
await this.initAfterCourseLoad();
this.sidebarItems = this.getSidebarItems();
this.courseActionItems = this.getCourseActionItems();
- this.updateVisibility(window.innerHeight);
- this.updateMenuPosition();
+ this.updateVisibleNavbarItems(window.innerHeight);
await this.updateRecentlyAccessedCourses();
}
/** Listen window resize event by height */
@HostListener('window: resize', ['$event'])
onResize() {
- this.dropdownOpen = false;
- this.dropdownClickNumber = 0;
- this.updateVisibility(window.innerHeight);
- this.updateMenuPosition();
- }
-
- /** Listen click event whether on outside of the menu or one of the items in the menu to close the dropdown menu */
- @HostListener('document: click', ['$event'])
- onClickCloseDropdownMenu() {
- if (this.dropdownOpen) {
- this.dropdownClickNumber += 1;
- if (this.dropdownClickNumber === 2) {
- this.dropdownOpen = false;
- this.dropdownClickNumber = 0;
- }
- }
+ this.updateVisibleNavbarItems(window.innerHeight);
+ if (!this.anyItemHidden) this.itemsDrop.close();
}
/** Update sidebar item's hidden property based on the window height to display three-dots */
- updateVisibility(height: number) {
- let thresholdLevelForCurrentSidebar = this.calculateThreshold();
+ updateVisibleNavbarItems(height: number) {
+ const threshold = this.calculateThreshold();
+ this.applyThreshold(threshold, height);
+ }
+
+ /** Applies the visibility threshold to sidebar items, determining which items should be hidden.*/
+ private applyThreshold(threshold: number, height: number) {
this.anyItemHidden = false;
this.hiddenItems = [];
-
- for (let i = 0; i < this.sidebarItems.length - 1; i++) {
- this.thresholdsForEachSidebarItem.unshift(thresholdLevelForCurrentSidebar);
- thresholdLevelForCurrentSidebar -= this.ITEM_HEIGHT;
- }
- this.thresholdsForEachSidebarItem.unshift(0);
-
- this.sidebarItems.forEach((item, index) => {
- item.hidden = height <= this.thresholdsForEachSidebarItem[index];
+ // Reverse the sidebar items to remove items starting from the bottom
+ const reversedSidebarItems = [...this.sidebarItems].reverse();
+ reversedSidebarItems.forEach((item, index) => {
+ const currentThreshold = threshold - index * this.ITEM_HEIGHT;
+ item.hidden = height <= currentThreshold;
if (item.hidden) {
this.anyItemHidden = true;
- this.hiddenItems.push(item);
+ this.hiddenItems.unshift(item);
}
});
}
- /** Calculate dropdown-menu position based on the number of entries in the sidebar */
- updateMenuPosition() {
- const leftSidebarItems: number = this.sidebarItems.length - this.hiddenItems.length;
- this.dropdownOffset = leftSidebarItems * this.ITEM_HEIGHT + this.BREADCRUMB_AND_NAVBAR_HEIGHT;
- }
-
/** Calculate threshold levels based on the number of entries in the sidebar */
calculateThreshold(): number {
- const numberOfSidebarItems: number = this.sidebarItems.length;
- return numberOfSidebarItems * this.ITEM_HEIGHT + this.WINDOW_OFFSET;
- }
-
- toggleDropdown() {
- this.dropdownOpen = !this.dropdownOpen;
- // Refresh click numbers after toggle
- if (!this.dropdownOpen) {
- this.dropdownClickNumber = 0;
- }
+ return this.sidebarItems.length * this.ITEM_HEIGHT + this.WINDOW_OFFSET;
}
/** initialize courses attribute by retrieving all courses from the server */
@@ -343,7 +316,7 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit
const examsItem: SidebarItem = this.getExamsItems();
sidebarItems.unshift(examsItem);
}
- if (isMessagingEnabled(this.course) || isCommunicationEnabled(this.course)) {
+ if (isCommunicationEnabled(this.course)) {
const communicationsItem: SidebarItem = this.getCommunicationsItems();
sidebarItems.push(communicationsItem);
}
diff --git a/src/test/javascript/spec/component/course/course-overview.component.spec.ts b/src/test/javascript/spec/component/course/course-overview.component.spec.ts
index 0336e5afffb2..cb3496dcb9b7 100644
--- a/src/test/javascript/spec/component/course/course-overview.component.spec.ts
+++ b/src/test/javascript/spec/component/course/course-overview.component.spec.ts
@@ -48,7 +48,7 @@ import { NotificationService } from 'app/shared/notification/notification.servic
import { MockNotificationService } from '../../helpers/mocks/service/mock-notification.service';
import { MockMetisConversationService } from '../../helpers/mocks/service/mock-metis-conversation.service';
import { CourseAccessStorageService } from 'app/course/course-access-storage.service';
-import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgbDropdown, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatSidenavModule } from '@angular/material/sidenav';
import { TranslateDirective } from 'app/shared/language/translate.directive';
@@ -56,6 +56,7 @@ import { MockLocalStorageService } from '../../helpers/mocks/service/mock-local-
import { LocalStorageService, SessionStorageService } from 'ngx-webstorage';
import { MockSyncStorage } from '../../helpers/mocks/service/mock-sync-storage.service';
import { ExamParticipationService } from 'app/exam/participate/exam-participation.service';
+import { NgbDropdownMocksModule } from '../../helpers/mocks/directive/ngbDropdownMocks.module';
const endDate1 = dayjs().add(1, 'days');
const visibleDate1 = dayjs().subtract(1, 'days');
@@ -146,6 +147,7 @@ describe('CourseOverviewComponent', () => {
let route: ActivatedRoute;
let findOneForRegistrationStub: jest.SpyInstance;
let findAllForDropdownSpy: jest.SpyInstance;
+ let itemsDrop: NgbDropdown;
let metisConversationService: MetisConversationService;
@@ -162,7 +164,14 @@ describe('CourseOverviewComponent', () => {
router = new MockRouter();
TestBed.configureTestingModule({
- imports: [ArtemisTestModule, RouterTestingModule.withRoutes([]), MockModule(MatSidenavModule), MockModule(NgbTooltipModule), MockModule(BrowserAnimationsModule)],
+ imports: [
+ ArtemisTestModule,
+ RouterTestingModule.withRoutes([]),
+ MockModule(MatSidenavModule),
+ MockModule(NgbTooltipModule),
+ MockModule(BrowserAnimationsModule),
+ NgbDropdownMocksModule,
+ ],
declarations: [
CourseOverviewComponent,
MockDirective(MockHasAnyAuthorityDirective),
@@ -199,6 +208,7 @@ describe('CourseOverviewComponent', () => {
{ provide: NotificationService, useClass: MockNotificationService },
{ provide: LocalStorageService, useClass: MockLocalStorageService },
{ provide: SessionStorageService, useClass: MockSyncStorage },
+ { provide: NgbDropdown, useClass: NgbDropdownMocksModule },
],
})
.compileComponents()
@@ -215,6 +225,7 @@ describe('CourseOverviewComponent', () => {
jhiWebsocketService = TestBed.inject(JhiWebsocketService);
courseAccessStorageService = TestBed.inject(CourseAccessStorageService);
metisConversationService = fixture.debugElement.injector.get(MetisConversationService);
+ itemsDrop = component.itemsDrop;
jhiWebsocketServiceReceiveStub = jest.spyOn(jhiWebsocketService, 'receive').mockReturnValue(of(quizExercise));
jhiWebsocketServiceSubscribeSpy = jest.spyOn(jhiWebsocketService, 'subscribe');
jest.spyOn(teamService, 'teamAssignmentUpdates', 'get').mockResolvedValue(of(new TeamAssignmentPayload()));
@@ -252,8 +263,7 @@ describe('CourseOverviewComponent', () => {
const notifyAboutCourseAccessStub = jest.spyOn(courseAccessStorageService, 'onCourseAccessed');
const getSidebarItems = jest.spyOn(component, 'getSidebarItems');
const getCourseActionItems = jest.spyOn(component, 'getCourseActionItems');
- const getUpdateVisibility = jest.spyOn(component, 'updateVisibility');
- const getUpdateMenuPosition = jest.spyOn(component, 'updateMenuPosition');
+ const getUpdateVisibility = jest.spyOn(component, 'updateVisibleNavbarItems');
findOneForDashboardStub.mockReturnValue(of(new HttpResponse({ body: course1, headers: new HttpHeaders() })));
getCourseStub.mockReturnValue(course1);
@@ -275,7 +285,6 @@ describe('CourseOverviewComponent', () => {
CourseAccessStorageService.MAX_DISPLAYED_RECENTLY_ACCESSED_COURSES_DROPDOWN,
);
expect(getUpdateVisibility).toHaveBeenCalledOnce();
- expect(getUpdateMenuPosition).toHaveBeenCalledOnce();
});
it('should create sidebar item for student course analytics dashboard if the feature is active', () => {
@@ -628,33 +637,24 @@ describe('CourseOverviewComponent', () => {
expect(component.courseActionItemClick).toHaveBeenCalledWith(component.courseActionItems[0]);
});
- it('should call updateVisibility and updateMenuPosition after window resizement', async () => {
- const getUpdateVisibility = jest.spyOn(component, 'updateVisibility');
- const getUpdateMenuPosition = jest.spyOn(component, 'updateMenuPosition');
+ it('should call updateVisibleNavbarItems after window resizement', async () => {
+ const getUpdateVisibility = jest.spyOn(component, 'updateVisibleNavbarItems');
await component.ngOnInit();
window.dispatchEvent(new Event('resize'));
expect(getUpdateVisibility).toHaveBeenCalled();
- expect(getUpdateMenuPosition).toHaveBeenCalled();
});
- it('should toggle dropdownOpen when toggleDropdown is called', () => {
- component.toggleDropdown();
- expect(component.dropdownOpen).toBeTrue();
-
- component.toggleDropdown();
- expect(component.dropdownOpen).toBeFalse();
- });
-
- it('should display and hide content of dropdown when dropdownOpen changes', () => {
- component.dropdownOpen = true;
+ it('should display content of dropdown when dropdownOpen changes', () => {
+ itemsDrop.open();
fixture.detectChanges();
- expect(fixture.nativeElement.querySelector('.dropdown-content').hidden).toBeFalse();
-
- component.dropdownOpen = false;
+ expect(component.itemsDrop.isOpen).toBeTrue();
+ });
+ it('should hide content of dropdown when dropdownOpen changes', () => {
+ itemsDrop.close();
fixture.detectChanges();
- expect(fixture.nativeElement.querySelector('.dropdown-content').hidden).toBeTrue();
+ expect(component.itemsDrop.isOpen).toBeFalse();
});
it('should display more icon and label if at least one item gets hidden in the sidebar', () => {
@@ -668,13 +668,11 @@ describe('CourseOverviewComponent', () => {
});
it('should change dropdownOpen when clicking on More', () => {
- component.dropdownOpen = false;
- fixture.detectChanges();
-
+ itemsDrop.close();
const clickOnMoreItem = fixture.nativeElement.querySelector('.three-dots');
clickOnMoreItem.click();
- expect(component.dropdownOpen).toBeTrue();
+ expect(fixture.nativeElement.querySelector('.dropdown-content').hidden).toBeFalse();
});
it('should apply exam-wrapper and exam-is-active if exam is started', () => {
diff --git a/src/test/javascript/spec/helpers/mocks/directive/ngbDropdownMocks.module.ts b/src/test/javascript/spec/helpers/mocks/directive/ngbDropdownMocks.module.ts
index dc5e94e4131b..f47e6e8e45b5 100644
--- a/src/test/javascript/spec/helpers/mocks/directive/ngbDropdownMocks.module.ts
+++ b/src/test/javascript/spec/helpers/mocks/directive/ngbDropdownMocks.module.ts
@@ -28,12 +28,20 @@ class NgbDropdownToggleMockDirective {}
class NgbDropdownMockDirective {
@Input() autoClose: boolean | 'outside' | 'inside';
@Input() dropdownClass: string;
- @Input() open = false;
+ @Input() isOpen = false;
@Input() placement: any;
@Input() popperOptions: (options: Partial) => Partial;
@Input() container: null | 'body';
@Input() display: 'dynamic' | 'static';
@Output() openChange = new EventEmitter();
+ open() {
+ this.isOpen = true;
+ this.openChange.emit(this.isOpen);
+ }
+ close() {
+ this.isOpen = false;
+ this.openChange.emit(this.isOpen);
+ }
}
@Directive({