From 4e30d18e7ba800e611251d16c00059978fcf43a5 Mon Sep 17 00:00:00 2001 From: Asli Aykan Date: Mon, 16 Dec 2024 22:09:04 +0300 Subject: [PATCH] added new section to sidebar --- .../course-conversations.component.html | 1 - .../course-conversations.component.ts | 26 +++---- .../app/overview/course-overview.service.ts | 68 +++++++++++++++---- .../app/shared/sidebar/sidebar.component.ts | 3 +- src/main/webapp/app/types/sidebar.ts | 4 +- .../webapp/i18n/de/student-dashboard.json | 1 + .../webapp/i18n/en/student-dashboard.json | 1 + .../course/course-overview.service.spec.ts | 32 +++++++-- .../course-conversations.component.spec.ts | 46 +++++++++++++ 9 files changed, 140 insertions(+), 42 deletions(-) diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html index 6b2bac7d8ada..7200a6e7b5ac 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html @@ -33,7 +33,6 @@ (onBrowsePressed)="openChannelOverviewDialog()" (onDirectChatPressed)="openCreateOneToOneChatDialog()" (onGroupChatPressed)="openCreateGroupChatDialog()" - [showAddOption]="CHANNEL_TYPE_SHOW_ADD_OPTION" [channelTypeIcon]="CHANNEL_TYPE_ICON" [sidebarItemAlwaysShow]="DEFAULT_SHOW_ALWAYS" [collapseState]="DEFAULT_COLLAPSE_STATE" diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts index bdc17d480a70..57ea79fc6372 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts @@ -13,6 +13,7 @@ import { PageType, SortDirection } from 'app/shared/metis/metis.util'; import { faBan, faBookmark, + faClock, faComment, faComments, faFile, @@ -27,7 +28,7 @@ import { } from '@fortawesome/free-solid-svg-icons'; import { ButtonType } from 'app/shared/components/button.component'; import { CourseWideSearchComponent, CourseWideSearchConfig } from 'app/overview/course-conversations/course-wide-search/course-wide-search.component'; -import { AccordionGroups, ChannelAccordionShowAdd, ChannelTypeIcons, CollapseState, SidebarCardElement, SidebarData, SidebarItemShowAlways } from 'app/types/sidebar'; +import { AccordionGroups, ChannelTypeIcons, CollapseState, SidebarCardElement, SidebarData, SidebarItemShowAlways } from 'app/types/sidebar'; import { CourseOverviewService } from 'app/overview/course-overview.service'; import { GroupChatCreateDialogComponent } from 'app/overview/course-conversations/dialogs/group-chat-create-dialog/group-chat-create-dialog.component'; import { defaultFirstLayerDialogOptions, defaultSecondLayerDialogOptions } from 'app/overview/course-conversations/other/conversation.util'; @@ -44,6 +45,7 @@ import { canCreateChannel } from 'app/shared/metis/conversations/conversation-pe const DEFAULT_CHANNEL_GROUPS: AccordionGroups = { favoriteChannels: { entityData: [] }, + recently: { entityData: [] }, generalChannels: { entityData: [] }, exerciseChannels: { entityData: [] }, lectureChannels: { entityData: [] }, @@ -52,18 +54,6 @@ const DEFAULT_CHANNEL_GROUPS: AccordionGroups = { savedPosts: { entityData: [] }, }; -const CHANNEL_TYPE_SHOW_ADD_OPTION: ChannelAccordionShowAdd = { - generalChannels: true, - exerciseChannels: true, - examChannels: true, - groupChats: true, - directMessages: true, - favoriteChannels: false, - lectureChannels: true, - hiddenChannels: false, - savedPosts: false, -}; - const CHANNEL_TYPE_ICON: ChannelTypeIcons = { generalChannels: faMessage, exerciseChannels: faList, @@ -74,6 +64,7 @@ const CHANNEL_TYPE_ICON: ChannelTypeIcons = { lectureChannels: faFile, hiddenChannels: faBan, savedPosts: faBookmark, + recently: faClock, }; const DEFAULT_COLLAPSE_STATE: CollapseState = { @@ -86,6 +77,7 @@ const DEFAULT_COLLAPSE_STATE: CollapseState = { lectureChannels: true, hiddenChannels: true, savedPosts: true, + recently: true, }; const DEFAULT_SHOW_ALWAYS: SidebarItemShowAlways = { @@ -98,6 +90,7 @@ const DEFAULT_SHOW_ALWAYS: SidebarItemShowAlways = { lectureChannels: false, hiddenChannels: false, savedPosts: true, + recently: true, }; @Component({ @@ -135,7 +128,6 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { openThreadOnFocus = false; selectedSavedPostStatus: null | SavedPostStatus = null; - readonly CHANNEL_TYPE_SHOW_ADD_OPTION = CHANNEL_TYPE_SHOW_ADD_OPTION; readonly CHANNEL_TYPE_ICON = CHANNEL_TYPE_ICON; readonly DEFAULT_COLLAPSE_STATE = DEFAULT_COLLAPSE_STATE; protected readonly DEFAULT_SHOW_ALWAYS = DEFAULT_SHOW_ALWAYS; @@ -409,8 +401,10 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { prepareSidebarData() { this.metisConversationService.forceRefresh().subscribe({ complete: () => { - this.sidebarConversations = this.courseOverviewService.mapConversationsToSidebarCardElements(this.conversationsOfUser); - this.accordionConversationGroups = this.courseOverviewService.groupConversationsByChannelType(this.conversationsOfUser, this.messagingEnabled); + this.sidebarConversations = this.courseOverviewService.mapConversationsToSidebarCardElements(this.course!, this.conversationsOfUser); + this.accordionConversationGroups = this.courseOverviewService.groupConversationsByChannelType(this.course!, this.conversationsOfUser, this.messagingEnabled); + const currentConversations = this.sidebarConversations?.filter((item) => item.isCurrent) || []; + this.accordionConversationGroups.recently.entityData = currentConversations; this.updateSidebarData(); }, }); diff --git a/src/main/webapp/app/overview/course-overview.service.ts b/src/main/webapp/app/overview/course-overview.service.ts index 97b42ff6dc3f..5f0d648a3b94 100644 --- a/src/main/webapp/app/overview/course-overview.service.ts +++ b/src/main/webapp/app/overview/course-overview.service.ts @@ -20,6 +20,7 @@ import { isGroupChatDTO } from 'app/entities/metis/conversation/group-chat.model import { ConversationService } from 'app/shared/metis/conversations/conversation.service'; import { StudentExam } from 'app/entities/student-exam.model'; import { SavedPostStatusMap } from 'app/entities/metis/posting.model'; +import { Course } from 'app/entities/course.model'; const DEFAULT_UNIT_GROUPS: AccordionGroups = { future: { entityData: [] }, @@ -58,6 +59,7 @@ const GROUP_DECISION_MATRIX: Record { + const aIsFavorite = a.conversation?.isFavorite ? 1 : 0; + const bIsFavorite = b.conversation?.isFavorite ? 1 : 0; + return bIsFavorite - aIsFavorite; + }); + } return groupedConversationGroups; } @@ -273,8 +291,8 @@ export class CourseOverviewService { return exams.map((exam, index) => this.mapExamToSidebarCardElement(exam, studentExams?.[index])); } - mapConversationsToSidebarCardElements(conversations: ConversationDTO[]) { - return conversations.map((conversation) => this.mapConversationToSidebarCardElement(conversation)); + mapConversationsToSidebarCardElements(course: Course, conversations: ConversationDTO[]) { + return conversations.map((conversation) => this.mapConversationToSidebarCardElement(course, conversation)); } mapLectureToSidebarCardElement(lecture: Lecture): SidebarCardElement { @@ -349,7 +367,26 @@ export class CourseOverviewService { } } - mapConversationToSidebarCardElement(conversation: ConversationDTO): SidebarCardElement { + mapConversationToSidebarCardElement(course: Course, conversation: ConversationDTO): SidebarCardElement { + let isCurrent = false; + const channelDTO = getAsChannelDTO(conversation); + const subTypeRefId = channelDTO?.subTypeReferenceId; + const now = dayjs(); + const oneAndHalfWeekBefore = now.subtract(1.5, 'week'); + const oneAndHalfWeekLater = now.add(1.5, 'week'); + let dueDate = null; + if (subTypeRefId && course.exercises && channelDTO?.subType === 'exercise') { + const exercise = course.exercises.find((exercise) => exercise.id === subTypeRefId); + dueDate = exercise?.dueDate || null; + } else if (subTypeRefId && course.lectures && channelDTO?.subType === 'lecture') { + const lecture = course.lectures.find((lecture) => lecture.id === subTypeRefId); + dueDate = lecture?.startDate || null; + } else if (subTypeRefId && course.exams && channelDTO?.subType === 'exam') { + const exam = course.exams.find((exam) => exam.id === subTypeRefId); + dueDate = exam?.startDate || null; + } + isCurrent = dueDate ? dayjs(dueDate).isBetween(oneAndHalfWeekBefore, oneAndHalfWeekLater, 'day', '[]') : false; + const conversationCardItem: SidebarCardElement = { title: this.conversationService.getConversationName(conversation) ?? '', id: conversation.id ?? '', @@ -357,6 +394,7 @@ export class CourseOverviewService { icon: this.getChannelIcon(conversation), conversation: conversation, size: 'S', + isCurrent: isCurrent, }; return conversationCardItem; } diff --git a/src/main/webapp/app/shared/sidebar/sidebar.component.ts b/src/main/webapp/app/shared/sidebar/sidebar.component.ts index f3ea292820bb..4c8592236464 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar.component.ts +++ b/src/main/webapp/app/shared/sidebar/sidebar.component.ts @@ -3,7 +3,7 @@ import { faFilter, faFilterCircleXmark, faHashtag, faPeopleGroup, faPlusCircle, import { ActivatedRoute, Params } from '@angular/router'; import { Subscription, distinctUntilChanged } from 'rxjs'; import { ProfileService } from '../layouts/profiles/profile.service'; -import { ChannelAccordionShowAdd, ChannelTypeIcons, CollapseState, SidebarCardSize, SidebarData, SidebarItemShowAlways, SidebarTypes } from 'app/types/sidebar'; +import { ChannelTypeIcons, CollapseState, SidebarCardSize, SidebarData, SidebarItemShowAlways, SidebarTypes } from 'app/types/sidebar'; import { SidebarEventService } from './sidebar-event.service'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { cloneDeep } from 'lodash-es'; @@ -32,7 +32,6 @@ export class SidebarComponent implements OnDestroy, OnChanges, OnInit { @Input() sidebarData: SidebarData; @Input() courseId?: number; @Input() itemSelected?: boolean; - @Input() showAddOption?: ChannelAccordionShowAdd; @Input() channelTypeIcon?: ChannelTypeIcons; @Input() collapseState: CollapseState; sidebarItemAlwaysShow = input.required(); diff --git a/src/main/webapp/app/types/sidebar.ts b/src/main/webapp/app/types/sidebar.ts index 48180c03ebfb..c3a0b5faabee 100644 --- a/src/main/webapp/app/types/sidebar.ts +++ b/src/main/webapp/app/types/sidebar.ts @@ -16,6 +16,7 @@ export type AccordionGroups = Record< >; export type ChannelGroupCategory = | 'favoriteChannels' + | 'recently' | 'generalChannels' | 'exerciseChannels' | 'lectureChannels' @@ -27,7 +28,6 @@ export type ChannelGroupCategory = export type CollapseState = { [key: string]: boolean; } & (Record | Record | Record | Record); -export type ChannelAccordionShowAdd = Record; export type ChannelTypeIcons = Record; export type SidebarItemShowAlways = { [key: string]: boolean; @@ -135,4 +135,6 @@ export interface SidebarCardElement { * Set for Conversation. Will be removed after refactoring */ conversation?: ConversationDTO; + + isCurrent?: boolean; } diff --git a/src/main/webapp/i18n/de/student-dashboard.json b/src/main/webapp/i18n/de/student-dashboard.json index 642af5f892fc..4d8efbed2004 100644 --- a/src/main/webapp/i18n/de/student-dashboard.json +++ b/src/main/webapp/i18n/de/student-dashboard.json @@ -82,6 +82,7 @@ "createDirectChat": "Direkt-Chat erstellen", "groupChats": "Gruppenchats", "directMessages": "Direktnachrichten", + "recently": "Kürzlich", "filterConversationPlaceholder": "Konversationen filtern" }, "menu": { diff --git a/src/main/webapp/i18n/en/student-dashboard.json b/src/main/webapp/i18n/en/student-dashboard.json index eb79ff327373..6a6fa4be8337 100644 --- a/src/main/webapp/i18n/en/student-dashboard.json +++ b/src/main/webapp/i18n/en/student-dashboard.json @@ -82,6 +82,7 @@ "createDirectChat": "Create direct chat", "groupChats": "Group Chats", "directMessages": "Direct Messages", + "recently": "Recently", "filterConversationPlaceholder": "Filter conversations" }, "menu": { diff --git a/src/test/javascript/spec/component/course/course-overview.service.spec.ts b/src/test/javascript/spec/component/course/course-overview.service.spec.ts index 728d0fb269a8..cd902510bb92 100644 --- a/src/test/javascript/spec/component/course/course-overview.service.spec.ts +++ b/src/test/javascript/spec/component/course/course-overview.service.spec.ts @@ -429,7 +429,7 @@ describe('CourseOverviewService', () => { jest.spyOn(service, 'getCorrespondingChannelSubType'); jest.spyOn(service, 'mapConversationToSidebarCardElement'); - const groupedConversations = service.groupConversationsByChannelType(conversations, true); + const groupedConversations = service.groupConversationsByChannelType(course, conversations, true); expect(groupedConversations['generalChannels'].entityData).toHaveLength(1); expect(groupedConversations['examChannels'].entityData).toHaveLength(1); @@ -445,7 +445,7 @@ describe('CourseOverviewService', () => { jest.spyOn(service, 'getCorrespondingChannelSubType'); jest.spyOn(service, 'mapConversationToSidebarCardElement'); - const groupedConversations = service.groupConversationsByChannelType(conversations, true); + const groupedConversations = service.groupConversationsByChannelType(course, conversations, true); expect(groupedConversations['generalChannels'].entityData).toHaveLength(2); expect(service.mapConversationToSidebarCardElement).toHaveBeenCalledTimes(2); @@ -460,21 +460,39 @@ describe('CourseOverviewService', () => { jest.spyOn(service, 'mapConversationToSidebarCardElement'); jest.spyOn(service, 'getConversationGroup'); jest.spyOn(service, 'getCorrespondingChannelSubType'); - const groupedConversations = service.groupConversationsByChannelType(conversations, true); + const groupedConversations = service.groupConversationsByChannelType(course, conversations, true); - expect(groupedConversations['generalChannels'].entityData).toHaveLength(2); + expect(groupedConversations['generalChannels'].entityData).toHaveLength(4); expect(groupedConversations['examChannels'].entityData).toHaveLength(1); expect(groupedConversations['exerciseChannels'].entityData).toHaveLength(1); expect(groupedConversations['favoriteChannels'].entityData).toHaveLength(1); expect(groupedConversations['hiddenChannels'].entityData).toHaveLength(1); expect(service.mapConversationToSidebarCardElement).toHaveBeenCalledTimes(6); expect(service.getConversationGroup).toHaveBeenCalledTimes(6); - expect(service.getCorrespondingChannelSubType).toHaveBeenCalledTimes(4); - expect(getAsChannelDTO(groupedConversations['generalChannels'].entityData[0].conversation)?.name).toBe('General'); - expect(getAsChannelDTO(groupedConversations['generalChannels'].entityData[1].conversation)?.name).toBe('General 2'); + expect(service.getCorrespondingChannelSubType).toHaveBeenCalledTimes(6); + expect(getAsChannelDTO(groupedConversations['generalChannels'].entityData[0].conversation)?.name).toBe('fav-channel'); + expect(getAsChannelDTO(groupedConversations['generalChannels'].entityData[1].conversation)?.name).toBe('General'); + expect(getAsChannelDTO(groupedConversations['generalChannels'].entityData[2].conversation)?.name).toBe('General 2'); expect(getAsChannelDTO(groupedConversations['examChannels'].entityData[0].conversation)?.name).toBe('exam-test'); expect(getAsChannelDTO(groupedConversations['exerciseChannels'].entityData[0].conversation)?.name).toBe('exercise-test'); expect(getAsChannelDTO(groupedConversations['favoriteChannels'].entityData[0].conversation)?.name).toBe('fav-channel'); expect(getAsChannelDTO(groupedConversations['hiddenChannels'].entityData[0].conversation)?.name).toBe('hidden-channel'); }); + + it('should not remove favorite conversations from their original section but keep them at the top of the related section', () => { + const conversations = [generalChannel, examChannel, exerciseChannel, favoriteChannel]; + + jest.spyOn(service, 'getCorrespondingChannelSubType'); + jest.spyOn(service, 'mapConversationToSidebarCardElement'); + jest.spyOn(service, 'getConversationGroup'); + const groupedConversations = service.groupConversationsByChannelType(course, conversations, true); + + expect(groupedConversations['favoriteChannels'].entityData).toContainEqual(expect.objectContaining({ id: favoriteChannel.id })); + + expect(groupedConversations['generalChannels'].entityData[0].id).toBe(favoriteChannel.id); + + expect(service.mapConversationToSidebarCardElement).toHaveBeenCalledTimes(4); + expect(service.getConversationGroup).toHaveBeenCalledTimes(4); + expect(service.getCorrespondingChannelSubType).toHaveBeenCalledTimes(4); + }); }); diff --git a/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts index 825d153b7af7..4cee724dfd94 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts @@ -57,6 +57,7 @@ examples.forEach((activeConversation) => { let acceptCodeOfConductSpy: jest.SpyInstance; let setActiveConversationSpy: jest.SpyInstance; let metisConversationService: MetisConversationService; + let courseOverviewService: CourseOverviewService; let modalService: NgbModal; let courseSidebarService: CourseSidebarService; let layoutService: LayoutService; @@ -130,6 +131,7 @@ examples.forEach((activeConversation) => { }); metisConversationService = TestBed.inject(MetisConversationService); + courseOverviewService = TestBed.inject(CourseOverviewService); courseSidebarService = TestBed.inject(CourseSidebarService); layoutService = TestBed.inject(LayoutService); activatedRoute = TestBed.inject(ActivatedRoute); @@ -158,6 +160,39 @@ examples.forEach((activeConversation) => { acceptCodeOfConductSpy = jest.spyOn(metisConversationService, 'acceptCodeOfConduct'); jest.spyOn(metisService, 'posts', 'get').mockReturnValue(postsSubject.asObservable()); modalService = TestBed.inject(NgbModal); + component.sidebarConversations = []; + + jest.spyOn(courseOverviewService, 'mapConversationsToSidebarCardElements').mockReturnValue([ + { + id: 1, + title: 'Test Channel 1', + isCurrent: true, + conversation: { id: 1 }, + size: 'S', + }, + { + id: 2, + title: 'Test Channel 2', + isCurrent: false, + conversation: { id: 2 }, + size: 'S', + }, + ]); + + jest.spyOn(courseOverviewService, 'groupConversationsByChannelType').mockReturnValue({ + recently: { + entityData: [ + { + id: 1, + title: 'Test Channel 1', + isCurrent: true, + conversation: { id: 1 }, + size: 'S', + }, + ], + }, + generalChannels: { entityData: [] }, + }); })); afterEach(() => { @@ -433,6 +468,17 @@ examples.forEach((activeConversation) => { // Since createChannelFn is undefined, prepareSidebarData should not be called expect(prepareSidebarDataSpy).not.toHaveBeenCalled(); }); + + it('should correctly populate the recently group in accordionConversationGroups using existing mocks', fakeAsync(() => { + (metisConversationService.forceRefresh as jest.Mock).mockReturnValue(of({})); + + component.prepareSidebarData(); + tick(); + const recentlyGroup = component.accordionConversationGroups.recently; + expect(recentlyGroup).toBeDefined(); + expect(recentlyGroup.entityData).toHaveLength(1); + expect(recentlyGroup.entityData[0].isCurrent).toBeTrue(); + })); }); describe('query parameter handling', () => {