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 612880e9c6cd..691e877f45b7 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 @@ -34,7 +34,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 94cfb4929066..17a73c85862d 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: [] }, + recents: { 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, + recents: faClock, }; const DEFAULT_COLLAPSE_STATE: CollapseState = { @@ -86,6 +77,7 @@ const DEFAULT_COLLAPSE_STATE: CollapseState = { lectureChannels: true, hiddenChannels: true, savedPosts: true, + recents: true, }; const DEFAULT_SHOW_ALWAYS: SidebarItemShowAlways = { @@ -98,6 +90,7 @@ const DEFAULT_SHOW_ALWAYS: SidebarItemShowAlways = { lectureChannels: false, hiddenChannels: false, savedPosts: true, + recents: 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.recents.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..6e13440242bb 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 +293,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 +369,28 @@ 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 relevantDate = null; + if (subTypeRefId && course.exercises && channelDTO?.subType === 'exercise') { + const exercise = course.exercises.find((exercise) => exercise.id === subTypeRefId); + const relevantDates = [exercise?.releaseDate, exercise?.dueDate].filter(Boolean); + isCurrent = relevantDates.some((date) => dayjs(date).isBetween(oneAndHalfWeekBefore, oneAndHalfWeekLater, 'day', '[]')); + } else if (subTypeRefId && course.lectures && channelDTO?.subType === 'lecture') { + const lecture = course.lectures.find((lecture) => lecture.id === subTypeRefId); + relevantDate = lecture?.startDate || null; + isCurrent = relevantDate ? dayjs(relevantDate).isBetween(oneAndHalfWeekBefore, oneAndHalfWeekLater, 'day', '[]') : false; + } else if (subTypeRefId && course.exams && channelDTO?.subType === 'exam') { + const exam = course.exams.find((exam) => exam.id === subTypeRefId); + relevantDate = exam?.startDate || null; + isCurrent = relevantDate ? dayjs(relevantDate).isBetween(oneAndHalfWeekBefore, oneAndHalfWeekLater, 'day', '[]') : false; + } + const conversationCardItem: SidebarCardElement = { title: this.conversationService.getConversationName(conversation) ?? '', id: conversation.id ?? '', @@ -357,6 +398,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 839f1583f930..4422958d48cd 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 { faCheckDouble, faFilter, faFilterCircleXmark, faHashtag, faPeopleGroup, 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'; @@ -33,7 +33,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..7143b45b9298 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' + | 'recents' | '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 1efa807cf985..a605350eabb0 100644 --- a/src/main/webapp/i18n/de/student-dashboard.json +++ b/src/main/webapp/i18n/de/student-dashboard.json @@ -83,7 +83,8 @@ "groupChats": "Gruppenchats", "directMessages": "Direktnachrichten", "filterConversationPlaceholder": "Konversationen filtern", - "setChannelAsRead": "Alle Kanäle als gelesen markieren" + "setChannelAsRead": "Alle Kanäle als gelesen markieren", + "recents": "Kürzliches" }, "menu": { "exercises": "Aufgaben", diff --git a/src/main/webapp/i18n/en/student-dashboard.json b/src/main/webapp/i18n/en/student-dashboard.json index a1be35bda0a4..0488b491b4ac 100644 --- a/src/main/webapp/i18n/en/student-dashboard.json +++ b/src/main/webapp/i18n/en/student-dashboard.json @@ -83,7 +83,8 @@ "groupChats": "Group Chats", "directMessages": "Direct Messages", "filterConversationPlaceholder": "Filter conversations", - "setChannelAsRead": "Mark all channels as read" + "setChannelAsRead": "Mark all channels as read", + "recents": "Recents" }, "menu": { "exercises": "Exercises", 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..26b6028a6bf6 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 @@ -4,7 +4,6 @@ import { ModelingExercise } from 'app/entities/modeling-exercise.model'; import { Exercise } from 'app/entities/exercise.model'; import { UMLDiagramType } from '@ls1intum/apollon'; import { Course } from 'app/entities/course.model'; -import dayjs from 'dayjs/esm'; import { Lecture } from 'app/entities/lecture.model'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { TranslateService } from '@ngx-translate/core'; @@ -15,6 +14,8 @@ import { TextExercise } from 'app/entities/text/text-exercise.model'; import { Exam } from 'app/entities/exam/exam.model'; import { ChannelDTO, ChannelSubType, getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { provideHttpClient } from '@angular/common/http'; +import dayjs from 'dayjs/esm'; +import { ConversationDTO, ConversationType } from 'app/entities/metis/conversation/conversation.model'; describe('CourseOverviewService', () => { let service: CourseOverviewService; @@ -429,7 +430,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 +446,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 +461,69 @@ 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(3); 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(5); + 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); + }); + + it('should correctly set isCurrent based on the date range in mapConversationToSidebarCardElement', () => { + const now = dayjs(); + const oneAndHalfWeekBefore = now.subtract(1.5, 'week'); + + const conversationWithinRange = { + id: 5, + subType: ChannelSubType.EXERCISE, + subTypeReferenceId: 101, + type: ConversationType.CHANNEL, + } as ConversationDTO; + + const conversationOutsideRange = { + subType: ChannelSubType.LECTURE, + subTypeReferenceId: 102, + type: ConversationType.CHANNEL, + } as ConversationDTO; + + const exerciseWithinRange = { id: 101, dueDate: oneAndHalfWeekBefore.add(3, 'day') } as unknown as Exercise; + const lectureOutsideRange = { id: 102, startDate: oneAndHalfWeekBefore.subtract(1, 'day') } as unknown as Lecture; + + course.exercises = [exerciseWithinRange]; + course.lectures = [lectureOutsideRange]; + + const sidebarCardWithinRange = service.mapConversationToSidebarCardElement(course, conversationWithinRange); + const sidebarCardOutsideRange = service.mapConversationToSidebarCardElement(course, conversationOutsideRange); + + expect(sidebarCardWithinRange.isCurrent).toBeTrue(); + expect(sidebarCardOutsideRange.isCurrent).toBeFalse(); + }); }); 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 7add13f9c64f..f07e123cb354 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({ + recents: { + 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 recents group in accordionConversationGroups using existing mocks', fakeAsync(() => { + (metisConversationService.forceRefresh as jest.Mock).mockReturnValue(of({})); + + component.prepareSidebarData(); + tick(); + const recentsGroup = component.accordionConversationGroups.recents; + expect(recentsGroup).toBeDefined(); + expect(recentsGroup.entityData).toHaveLength(1); + expect(recentsGroup.entityData[0].isCurrent).toBeTrue(); + })); }); describe('query parameter handling', () => {