diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java index ed1b4e45d478..7e16e17c1698 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java @@ -232,6 +232,7 @@ public ResponseEntity createChannel(@PathVariable Long courseId, @Re channelToCreate.setIsAnnouncementChannel(channelDTO.getIsAnnouncementChannel()); channelToCreate.setIsArchived(false); channelToCreate.setDescription(channelDTO.getDescription()); + channelToCreate.setIsCourseWide(channelDTO.getIsCourseWide()); if (channelToCreate.getName() != null && channelToCreate.getName().trim().startsWith("$")) { throw new BadRequestAlertException("User generated channels cannot start with $", "channel", "channelNameInvalid"); 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..14d096049001 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 @@ -243,7 +243,7 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { this.channelActions$ .pipe( debounceTime(500), - distinctUntilChanged((prev, curr) => prev.action === curr.action && prev.channel.id === curr.channel.id), + distinctUntilChanged((prev, curr) => prev.action === curr.action && prev.channel.id === curr.channel.id && prev.channel.name === curr.channel.name), takeUntil(this.ngUnsubscribe), ) .subscribe((channelAction) => { diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.html b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.html index 194d404fc58b..541123af9255 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.html +++ b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.html @@ -65,6 +65,31 @@ > + +
+
+ +
+ + + + +
+ +
+
diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.ts b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.ts index bd471be6ca8b..a8863dd0490f 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.ts +++ b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, OnChanges, OnDestroy, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, OnChanges, OnDestroy, OnInit, Output, output } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { Subject, takeUntil } from 'rxjs'; @@ -7,6 +7,7 @@ export interface ChannelFormData { description?: string; isPublic?: boolean; isAnnouncementChannel?: boolean; + isCourseWideChannel?: boolean; } export type ChannelType = 'PUBLIC' | 'PRIVATE'; @@ -25,10 +26,12 @@ export class ChannelFormComponent implements OnInit, OnChanges, OnDestroy { description: undefined, isPublic: undefined, isAnnouncementChannel: undefined, + isCourseWideChannel: undefined, }; @Output() formSubmitted: EventEmitter = new EventEmitter(); @Output() channelTypeChanged: EventEmitter = new EventEmitter(); @Output() isAnnouncementChannelChanged: EventEmitter = new EventEmitter(); + isCourseWideChannelChanged = output(); form: FormGroup; @@ -50,6 +53,10 @@ export class ChannelFormComponent implements OnInit, OnChanges, OnDestroy { return this.form.get('isAnnouncementChannel'); } + get isisCourseWideChannelControl() { + return this.form.get('isCourseWideChannel'); + } + get isSubmitPossible() { return !this.form.invalid; } @@ -81,6 +88,7 @@ export class ChannelFormComponent implements OnInit, OnChanges, OnDestroy { description: [undefined, [Validators.maxLength(250)]], isPublic: [true, [Validators.required]], isAnnouncementChannel: [false, [Validators.required]], + isCourseWideChannel: [false, [Validators.required]], }); if (this.isPublicControl) { @@ -94,5 +102,11 @@ export class ChannelFormComponent implements OnInit, OnChanges, OnDestroy { this.isAnnouncementChannelChanged.emit(value); }); } + + if (this.isisCourseWideChannelControl) { + this.isisCourseWideChannelControl.valueChanges.pipe(takeUntil(this.ngUnsubscribe)).subscribe((value) => { + this.isCourseWideChannelChanged.emit(value); + }); + } } } diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.html b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.html index e7c6413b8b22..87535ee616b0 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.html +++ b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.html @@ -4,6 +4,7 @@
diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.ts b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.ts index 28d5f0f710d4..6f18cc3e03a8 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.ts +++ b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.ts @@ -19,6 +19,8 @@ export class ChannelsCreateDialogComponent extends AbstractDialogComponent { channelToCreate: ChannelDTO = new ChannelDTO(); isPublicChannel = true; isAnnouncementChannel = false; + isCourseWideChannel = false; + onChannelTypeChanged($event: ChannelType) { this.isPublicChannel = $event === 'PUBLIC'; } @@ -27,16 +29,21 @@ export class ChannelsCreateDialogComponent extends AbstractDialogComponent { this.isAnnouncementChannel = $event; } + onIsCourseWideChannelChanged($event: boolean) { + this.isCourseWideChannel = $event; + } + onFormSubmitted($event: ChannelFormData) { this.createChannel($event); } createChannel(formData: ChannelFormData) { - const { name, description, isPublic, isAnnouncementChannel } = formData; + const { name, description, isPublic, isAnnouncementChannel, isCourseWideChannel } = formData; this.channelToCreate.name = name ? name.trim() : undefined; this.channelToCreate.description = description ? description.trim() : undefined; this.channelToCreate.isPublic = isPublic; this.channelToCreate.isAnnouncementChannel = isAnnouncementChannel; + this.channelToCreate.isCourseWide = isCourseWideChannel; this.close(this.channelToCreate); } } diff --git a/src/main/webapp/i18n/de/conversation.json b/src/main/webapp/i18n/de/conversation.json index 1e35654026e4..d3319ce67e1d 100644 --- a/src/main/webapp/i18n/de/conversation.json +++ b/src/main/webapp/i18n/de/conversation.json @@ -208,6 +208,7 @@ "createChannel": { "titlePublicChannel": "Erstelle einen öffentlichen", "titlePrivateChannel": "Erstelle einen privaten", + "titleCourseWideChannel": "kursweit", "titleAnnouncementChannel": "Ankündigungskanal", "titleRegularChannel": "Kanal", "description": "Ein Kanal ist eine Möglichkeit, Menschen für ein Projekt, ein Thema oder nur zum Spaß zusammenzubringen. Du kannst so viele Kanäle erstellen, wie du möchtest. Du wirst der / die erste Kanalmoderator:in werden. Du wirst den Kanal nicht verlassen können.", @@ -236,6 +237,12 @@ "true": "Ankündigungskanal", "false": "Uneingeschränkter Kanal" }, + "isCourseWideChannelInput": { + "label": "Kanalbereich", + "explanation": "In einem kursweiten Kanal werden alle Benutzer des Kurses automatisch hinzugefügt. In einem ausgewählten Kanal kannst du die hinzuzufügenden Benutzer manuell auswählen.", + "true": "Kursweiter Kanal", + "false": "Ausgewählter Kanal" + }, "createButton": "Kanal erstellen" } } diff --git a/src/main/webapp/i18n/en/conversation.json b/src/main/webapp/i18n/en/conversation.json index 93c350bf4c55..8a56f4ad8025 100644 --- a/src/main/webapp/i18n/en/conversation.json +++ b/src/main/webapp/i18n/en/conversation.json @@ -208,6 +208,7 @@ "createChannel": { "titlePublicChannel": "Create a public", "titlePrivateChannel": "Create a private", + "titleCourseWideChannel": "course-wide", "titleAnnouncementChannel": "announcement channel", "titleRegularChannel": "channel", "description": "A channel is a way to group people together around a project, a topic, or just for fun. You can create as many channels as you want. You will become the first channel moderator. You will not be able to leave the channel.", @@ -236,6 +237,12 @@ "true": "Announcement Channel", "false": "Unrestricted Channel" }, + "isCourseWideChannelInput": { + "label": "Channel Scope", + "explanation": "In a course-wide channel, all users in the course are automatically added. In a selective channel, you can manually select the users to be added after creation.", + "true": "Course-wide Channel", + "false": "Selective Channel" + }, "createButton": "Create Channel" } } diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/AbstractConversationTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/AbstractConversationTest.java index ee5556a87372..0c1952c01941 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/AbstractConversationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/AbstractConversationTest.java @@ -180,6 +180,7 @@ ChannelDTO createChannel(boolean isPublicChannel, String name) throws Exception channelDTO.setIsPublic(isPublicChannel); channelDTO.setIsAnnouncementChannel(false); channelDTO.setDescription("general channel"); + channelDTO.setIsCourseWide(false); var chat = request.postWithResponseBody("/api/courses/" + exampleCourseId + "/channels", channelDTO, ChannelDTO.class, HttpStatus.CREATED); resetWebsocketMock(); diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java index fcdaed430ebd..eb19f84cd171 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java @@ -141,6 +141,7 @@ private void isAllowedToCreateChannelTest(boolean isPublicChannel, String loginN channelDTO.setIsPublic(isPublicChannel); channelDTO.setIsAnnouncementChannel(false); channelDTO.setDescription("general channel"); + channelDTO.setIsCourseWide(false); // when var chat = request.postWithResponseBody("/api/courses/" + exampleCourseId + "/channels", channelDTO, ChannelDTO.class, HttpStatus.CREATED); @@ -175,6 +176,7 @@ void createTest_messagingDeactivated(CourseInformationSharingConfiguration cours channelDTO.setIsAnnouncementChannel(false); channelDTO.setName(TEST_PREFIX); channelDTO.setDescription("general channel"); + channelDTO.setIsCourseWide(false); expectCreateForbidden(channelDTO); @@ -193,6 +195,7 @@ void update_messagingFeatureDeactivated_shouldReturnForbidden() throws Exception channelDTO.setIsAnnouncementChannel(false); channelDTO.setName(TEST_PREFIX); channelDTO.setDescription("general channel"); + channelDTO.setIsCourseWide(false); expectUpdateForbidden(1L, channelDTO); @@ -238,6 +241,7 @@ void createChannel_asNonCourseInstructorOrTutorOrEditor_shouldReturnForbidden(bo channelDTO.setIsPublic(isPublicChannel); channelDTO.setIsAnnouncementChannel(false); channelDTO.setDescription("general channel"); + channelDTO.setIsCourseWide(false); // then expectCreateForbidden(channelDTO); @@ -936,6 +940,7 @@ void createFeedbackChannel_asStudent_shouldReturnForbidden() { channelDTO.setDescription("Discussion channel for feedback"); channelDTO.setIsPublic(true); channelDTO.setIsAnnouncementChannel(false); + channelDTO.setIsCourseWide(false); FeedbackChannelRequestDTO feedbackChannelRequest = new FeedbackChannelRequestDTO(channelDTO, List.of("Sample feedback text"), "Sample testName"); @@ -960,6 +965,7 @@ void createFeedbackChannel_asInstructor_shouldCreateChannel() { channelDTO.setDescription("Discussion channel for feedback"); channelDTO.setIsPublic(true); channelDTO.setIsAnnouncementChannel(false); + channelDTO.setIsCourseWide(false); FeedbackChannelRequestDTO feedbackChannelRequest = new FeedbackChannelRequestDTO(channelDTO, List.of("Sample feedback text"), "Sample testName"); diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.spec.ts index 6e69f4f0066c..44439e8cc5af 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.spec.ts @@ -13,6 +13,7 @@ describe('ChannelFormComponent', () => { const validDescription = 'This is a general channel'; const validIsPublic = true; const validIsAnnouncementChannel = false; + const validIsCourseWideChannel = false; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -73,6 +74,7 @@ describe('ChannelFormComponent', () => { description: undefined, isPublic: validIsPublic, isAnnouncementChannel: validIsAnnouncementChannel, + isCourseWideChannel: validIsCourseWideChannel, }; clickSubmitButton(true, expectChannelData); @@ -95,6 +97,7 @@ describe('ChannelFormComponent', () => { description: validDescription, isPublic: validIsPublic, isAnnouncementChannel: validIsAnnouncementChannel, + isCourseWideChannel: validIsCourseWideChannel, }; clickSubmitButton(true, expectChannelData); diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.spec.ts index d8e311cd0703..0ee19c18c2c4 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.spec.ts @@ -4,7 +4,7 @@ import { ChannelsCreateDialogComponent } from 'app/overview/course-conversations import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { MockPipe, MockProvider } from 'ng-mocks'; import { Course } from 'app/entities/course.model'; -import { Component, EventEmitter, Output } from '@angular/core'; +import { Component, output } from '@angular/core'; import { ChannelFormData, ChannelType } from 'app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component'; import { By } from '@angular/platform-browser'; import { Channel } from 'app/entities/metis/conversation/channel.model'; @@ -15,9 +15,10 @@ import { initializeDialog } from '../dialog-test-helpers'; template: '', }) class ChannelFormStubComponent { - @Output() formSubmitted: EventEmitter = new EventEmitter(); - @Output() channelTypeChanged: EventEmitter = new EventEmitter(); - @Output() isAnnouncementChannelChanged: EventEmitter = new EventEmitter(); + formSubmitted = output(); + channelTypeChanged = output(); + isAnnouncementChannelChanged = output(); + isCourseWideChannelChanged = output(); } describe('ChannelsCreateDialogComponent', () => { @@ -47,6 +48,13 @@ describe('ChannelsCreateDialogComponent', () => { expect(component).toBeTruthy(); }); + it('should initialize the dialog correctly', () => { + const initializeSpy = jest.spyOn(component, 'initialize'); + component.initialize(); + expect(initializeSpy).toHaveBeenCalledOnce(); + expect(component.course).toBe(course); + }); + it('clicking close button in modal header should dismiss the modal', () => { const closeButton = fixture.debugElement.nativeElement.querySelector('.modal-header button'); const activeModal = TestBed.inject(NgbActiveModal); @@ -69,6 +77,13 @@ describe('ChannelsCreateDialogComponent', () => { expect(component.isAnnouncementChannel).toBeTrue(); }); + it('should change channel scope type when channel scope type is changed in channel form', () => { + expect(component.isCourseWideChannel).toBeFalse(); + const form: ChannelFormStubComponent = fixture.debugElement.query(By.directive(ChannelFormStubComponent)).componentInstance; + form.isCourseWideChannelChanged.emit(true); + expect(component.isCourseWideChannel).toBeTrue(); + }); + it('should close modal with the channel to create when form is submitted', () => { const activeModal = TestBed.inject(NgbActiveModal); const closeSpy = jest.spyOn(activeModal, 'close'); @@ -89,4 +104,46 @@ describe('ChannelsCreateDialogComponent', () => { expect(closeSpy).toHaveBeenCalledOnce(); expect(closeSpy).toHaveBeenCalledWith(expectedChannel); }); + + it('should call createChannel with correct data', () => { + const createChannelSpy = jest.spyOn(component, 'createChannel'); + + const formData: ChannelFormData = { + name: 'testChannel', + description: 'Test description', + isPublic: false, + isAnnouncementChannel: true, + isCourseWideChannel: false, + }; + + const form: ChannelFormStubComponent = fixture.debugElement.query(By.directive(ChannelFormStubComponent)).componentInstance; + form.formSubmitted.emit(formData); + + expect(createChannelSpy).toHaveBeenCalledOnce(); + expect(createChannelSpy).toHaveBeenCalledWith(formData); + }); + + it('should close modal when createChannel is called', () => { + const activeModal = TestBed.inject(NgbActiveModal); + const closeSpy = jest.spyOn(activeModal, 'close'); + + const formData: ChannelFormData = { + name: 'testChannel', + description: 'Test description', + isPublic: true, + isAnnouncementChannel: false, + isCourseWideChannel: true, + }; + + component.createChannel(formData); + + expect(closeSpy).toHaveBeenCalledOnce(); + expect(closeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: formData.name, + description: formData.description, + isPublic: formData.isPublic, + }), + ); + }); }); diff --git a/src/test/playwright/e2e/course/CourseMessages.spec.ts b/src/test/playwright/e2e/course/CourseMessages.spec.ts index d251556937ce..d606bbd43ca8 100644 --- a/src/test/playwright/e2e/course/CourseMessages.spec.ts +++ b/src/test/playwright/e2e/course/CourseMessages.spec.ts @@ -75,6 +75,19 @@ test.describe('Course messages', { tag: '@fast' }, () => { await expect(courseMessages.getName()).toContainText(name); }); + test('Instructor should be able to create a public course-wide unrestricted channel', async ({ login, courseMessages }) => { + await login(instructor, `/courses/${course.id}/communication`); + const name = 'public-cw-unrstct-ch'; + await courseMessages.createChannelButton(); + await courseMessages.setName(name); + await courseMessages.setDescription('A public unrestricted channel'); + await courseMessages.setPublic(); + await courseMessages.setUnrestrictedChannel(); + await courseMessages.setCourseWideChannel(); + await courseMessages.createChannel(false, true); + await expect(courseMessages.getName()).toContainText(name); + }); + test('Instructor should be able to create a private unrestricted channel', async ({ login, courseMessages }) => { await login(instructor, `/courses/${course.id}/communication`); const name = 'private-unrstct-ch'; diff --git a/src/test/playwright/support/pageobjects/course/CourseMessagesPage.ts b/src/test/playwright/support/pageobjects/course/CourseMessagesPage.ts index 2a07b1fbf404..c7645ce58e73 100644 --- a/src/test/playwright/support/pageobjects/course/CourseMessagesPage.ts +++ b/src/test/playwright/support/pageobjects/course/CourseMessagesPage.ts @@ -112,6 +112,13 @@ export class CourseMessagesPage { await this.page.locator('.modal-content label[for="public"]').click(); } + /** + * Marks a channel as course-wide in the modal dialog. + */ + async setCourseWideChannel() { + await this.page.locator('.modal-content label[for="isCourseWideChannel"]').click(); + } + /** * Marks a channel as an announcement channel in the modal dialog. */