From 05448370788102525a02b06804d0aac40035abea Mon Sep 17 00:00:00 2001 From: Tim Cremer Date: Wed, 9 Oct 2024 11:46:55 +0200 Subject: [PATCH] First draft implementation: Propose FAQ as editor --- .../repository/FaqRepository.java | 3 + .../communication/web/FaqResource.java | 39 ++++++++++-- .../course-management-tab-bar.component.html | 2 +- .../course-management-card.component.html | 2 +- src/main/webapp/app/entities/faq.model.ts | 6 +- .../webapp/app/faq/faq-update.component.ts | 6 +- src/main/webapp/app/faq/faq.component.html | 62 +++++++++++++------ src/main/webapp/app/faq/faq.component.ts | 40 +++++++++++- src/main/webapp/app/faq/faq.service.ts | 14 +++++ .../course-faq/course-faq.component.ts | 4 +- src/main/webapp/i18n/de/faq.json | 14 +++-- src/main/webapp/i18n/en/faq.json | 14 +++-- 12 files changed, 166 insertions(+), 40 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java index bd8bb8989995..0361014a2076 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java @@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional; import de.tum.cit.aet.artemis.communication.domain.Faq; +import de.tum.cit.aet.artemis.communication.domain.FaqState; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; /** @@ -30,6 +31,8 @@ public interface FaqRepository extends ArtemisJpaRepository { """) Set findAllCategoriesByCourseId(@Param("courseId") Long courseId); + Set findAllByCourseIdAndFaqState(Long courseId, FaqState faqState); + @Transactional @Modifying void deleteAllByCourseId(Long courseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java index 91a542aaa220..3a513afcc1d6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java @@ -23,12 +23,14 @@ import org.springframework.web.bind.annotation.RestController; import de.tum.cit.aet.artemis.communication.domain.Faq; +import de.tum.cit.aet.artemis.communication.domain.FaqState; import de.tum.cit.aet.artemis.communication.dto.FaqDTO; import de.tum.cit.aet.artemis.communication.repository.FaqRepository; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.security.Role; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastEditor; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastInstructor; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; @@ -72,7 +74,7 @@ public FaqResource(CourseRepository courseRepository, AuthorizationCheckService * @throws URISyntaxException if the Location URI syntax is incorrect */ @PostMapping("courses/{courseId}/faqs") - @EnforceAtLeastInstructor + @EnforceAtLeastEditor public ResponseEntity createFaq(@RequestBody Faq faq, @PathVariable Long courseId) throws URISyntaxException { log.debug("REST request to save Faq : {}", faq); if (faq.getId() != null) { @@ -82,7 +84,7 @@ public ResponseEntity createFaq(@RequestBody Faq faq, @PathVariable Long if (faq.getCourse() == null || !faq.getCourse().getId().equals(courseId)) { throw new BadRequestAlertException("Course ID in path and FAQ do not match", ENTITY_NAME, "courseIdMismatch"); } - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, faq.getCourse(), null); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, faq.getCourse(), null); Faq savedFaq = faqRepository.save(faq); FaqDTO dto = new FaqDTO(savedFaq); @@ -99,13 +101,14 @@ public ResponseEntity createFaq(@RequestBody Faq faq, @PathVariable Long * if the faq is not valid or if the faq course id does not match with the path variable */ @PutMapping("courses/{courseId}/faqs/{faqId}") - @EnforceAtLeastInstructor + @EnforceAtLeastEditor public ResponseEntity updateFaq(@RequestBody Faq faq, @PathVariable Long faqId, @PathVariable Long courseId) { log.debug("REST request to update Faq : {}", faq); if (faqId == null || !faqId.equals(faq.getId())) { throw new BadRequestAlertException("Id of FAQ and path must match", ENTITY_NAME, "idNull"); } - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, faq.getCourse(), null); + Course course = courseRepository.findByIdElseThrow(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, null); Faq existingFaq = faqRepository.findByIdElseThrow(faqId); if (!Objects.equals(existingFaq.getCourse().getId(), courseId)) { throw new BadRequestAlertException("Course ID of the FAQ provided courseID must match", ENTITY_NAME, "idNull"); @@ -174,6 +177,34 @@ public ResponseEntity> getFaqForCourse(@PathVariable Long courseId) return ResponseEntity.ok().body(faqDTOS); } + /** + * GET /courses/:courseId/faq-status/:faqState : get all the faqs of a course in the specified status + * + * @param courseId the courseId of the course for which all faqs should be returned + * @param faqState the state of all returned FAQs + * @return the ResponseEntity with status 200 (OK) and the list of faqs in body + */ + @GetMapping("courses/{courseId}/faq-state/{faqState}") + @EnforceAtLeastStudent + public ResponseEntity> getAllFaqForCourseByStatus(@PathVariable Long courseId, @PathVariable String faqState) { + log.debug("REST request to get all Faqs for the course with id : {}", courseId); + FaqState retrivedState = defineState(faqState); + Course course = courseRepository.findByIdElseThrow(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, null); + Set faqs = faqRepository.findAllByCourseIdAndFaqState(courseId, retrivedState); + Set faqDTOS = faqs.stream().map(FaqDTO::new).collect(Collectors.toSet()); + return ResponseEntity.ok().body(faqDTOS); + } + + private FaqState defineState(String faqState) { + return switch (faqState) { + case "ACCEPTED" -> FaqState.ACCEPTED; + case "REJECTED" -> FaqState.REJECTED; + case "PROPOSED" -> FaqState.PROPOSED; + default -> throw new IllegalArgumentException("Unknown state: " + faqState); + }; + } + /** * GET /courses/:courseId/faq-categories : get all the faq categories of a course * diff --git a/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html b/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html index abbfdaf7c010..36645abad973 100644 --- a/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html +++ b/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html @@ -72,7 +72,7 @@ } - @if (course.isAtLeastInstructor && course.faqEnabled) { + @if (course.isAtLeastEditor && course.faqEnabled) { diff --git a/src/main/webapp/app/course/manage/overview/course-management-card.component.html b/src/main/webapp/app/course/manage/overview/course-management-card.component.html index d13dee53b6e5..c590b29a0d75 100644 --- a/src/main/webapp/app/course/manage/overview/course-management-card.component.html +++ b/src/main/webapp/app/course/manage/overview/course-management-card.component.html @@ -339,7 +339,7 @@

}
- - - - + @if (isAtleastInstrucor) { + + + + + } @else { + + + + + }
@@ -64,6 +71,10 @@

+ + + + @@ -83,6 +94,9 @@

+ +

+
@for (category of faq.categories; track category) { @@ -90,27 +104,39 @@

}

-
+ @if (faq.faqState == FaqState.PROPOSED && isAtleastInstrucor) { +
+ + +
+ }
- - + @if (isAtleastInstrucor) { + + }
diff --git a/src/main/webapp/app/faq/faq.component.ts b/src/main/webapp/app/faq/faq.component.ts index 3790932a47a8..a593206a7848 100644 --- a/src/main/webapp/app/faq/faq.component.ts +++ b/src/main/webapp/app/faq/faq.component.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy, OnInit, inject } from '@angular/core'; -import { Faq } from 'app/entities/faq.model'; -import { faEdit, faFilter, faPencilAlt, faPlus, faSort, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { Faq, FaqState } from 'app/entities/faq.model'; +import { faCancel, faCheck, faEdit, faFilter, faPencilAlt, faPlus, faSort, faTrash } from '@fortawesome/free-solid-svg-icons'; import { Subject } from 'rxjs'; import { map } from 'rxjs/operators'; import { AlertService } from 'app/core/util/alert.service'; @@ -15,6 +15,9 @@ import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-catego import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; +import { AccountService } from 'app/core/auth/account.service'; +import { Course } from 'app/entities/course.model'; +import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'jhi-faq', @@ -24,11 +27,14 @@ import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; imports: [ArtemisSharedModule, CustomExerciseCategoryBadgeComponent, ArtemisSharedComponentModule, ArtemisMarkdownModule], }) export class FaqComponent implements OnInit, OnDestroy { + protected readonly FaqState = FaqState; faqs: Faq[]; + course: Course; filteredFaqs: Faq[]; existingCategories: FaqCategory[]; courseId: number; hasCategories: boolean = false; + isAtleastInstrucor: boolean = false; private dialogErrorSource = new Subject(); dialogError$ = this.dialogErrorSource.asObservable(); @@ -44,11 +50,15 @@ export class FaqComponent implements OnInit, OnDestroy { faPencilAlt = faPencilAlt; faFilter = faFilter; faSort = faSort; + faCancel = faCancel; + faCheck = faCheck; private faqService = inject(FaqService); private route = inject(ActivatedRoute); private alertService = inject(AlertService); private sortService = inject(SortService); + private accountService = inject(AccountService); + private translateService = inject(TranslateService); constructor() { this.predicate = 'id'; @@ -59,6 +69,14 @@ export class FaqComponent implements OnInit, OnDestroy { this.courseId = Number(this.route.snapshot.paramMap.get('courseId')); this.loadAll(); this.loadCourseFaqCategories(this.courseId); + this.route.data.subscribe((data) => { + const course = data['course']; + if (course) { + this.course = course; + this.isAtleastInstrucor = this.accountService.isAtLeastInstructorInCourse(course); + console.log(this.isAtleastInstrucor); + } + }); } ngOnDestroy(): void { @@ -121,4 +139,22 @@ export class FaqComponent implements OnInit, OnDestroy { }); this.applyFilters(); } + + rejectFaq(courseId: number, faq: Faq) { + faq.faqState = FaqState.REJECTED; + faq.course = this.course; + this.faqService.update(courseId, faq).subscribe({ + next: () => this.alertService.success(this.translateService.instant('artemisApp.faq.rejected', { id: faq.id })), + error: (error: HttpErrorResponse) => this.dialogErrorSource.next(error.message), + }); + } + + acceptProposedFaq(courseId: number, faq: Faq) { + faq.faqState = FaqState.ACCEPTED; + faq.course = this.course; + this.faqService.update(courseId, faq).subscribe({ + next: () => this.alertService.success(this.translateService.instant('artemisApp.faq.accepted', { id: faq.id })), + error: (error: HttpErrorResponse) => this.dialogErrorSource.next(error.message), + }); + } } diff --git a/src/main/webapp/app/faq/faq.service.ts b/src/main/webapp/app/faq/faq.service.ts index d0c80cf72e94..c7c9307c6106 100644 --- a/src/main/webapp/app/faq/faq.service.ts +++ b/src/main/webapp/app/faq/faq.service.ts @@ -24,7 +24,13 @@ export class FaqService { ); } + proposeFaq(courseId: number, faq: Faq) { + faq.faqState = FaqState.PROPOSED; + this.create(courseId, faq); + } + update(courseId: number, faq: Faq): Observable { + console.log(faq); const copy = FaqService.convertFaqFromClient(faq); return this.http.put(`${this.resourceUrl}/${courseId}/faqs/${faq.id}`, copy, { observe: 'response' }).pipe( map((res: EntityResponseType) => { @@ -47,6 +53,14 @@ export class FaqService { .pipe(map((res: EntityArrayResponseType) => FaqService.convertFaqCategoryArrayFromServer(res))); } + findAllByCourseIdAndState(courseId: number, faqState: FaqState): Observable { + return this.http + .get(`${this.resourceUrl}/${courseId}/faq-state/${faqState}`, { + observe: 'response', + }) + .pipe(map((res: EntityArrayResponseType) => FaqService.convertFaqCategoryArrayFromServer(res))); + } + delete(courseId: number, faqId: number): Observable> { return this.http.delete(`${this.resourceUrl}/${courseId}/faqs/${faqId}`, { observe: 'response' }); } diff --git a/src/main/webapp/app/overview/course-faq/course-faq.component.ts b/src/main/webapp/app/overview/course-faq/course-faq.component.ts index 51ce0a81b3b2..4adf721aed9e 100644 --- a/src/main/webapp/app/overview/course-faq/course-faq.component.ts +++ b/src/main/webapp/app/overview/course-faq/course-faq.component.ts @@ -8,7 +8,7 @@ import { SidebarData } from 'app/types/sidebar'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { CourseFaqAccordionComponent } from 'app/overview/course-faq/course-faq-accordion-component'; -import { Faq } from 'app/entities/faq.model'; +import { Faq, FaqState } from 'app/entities/faq.model'; import { FaqService } from 'app/faq/faq.service'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { AlertService } from 'app/core/util/alert.service'; @@ -67,7 +67,7 @@ export class CourseFaqComponent implements OnInit, OnDestroy { private loadFaqs() { this.faqService - .findAllByCourseId(this.courseId) + .findAllByCourseIdAndState(this.courseId, FaqState.ACCEPTED) .pipe(map((res: HttpResponse) => res.body)) .subscribe({ next: (res: Faq[]) => { diff --git a/src/main/webapp/i18n/de/faq.json b/src/main/webapp/i18n/de/faq.json index 0cb07d298310..1dd3b4f2ac19 100644 --- a/src/main/webapp/i18n/de/faq.json +++ b/src/main/webapp/i18n/de/faq.json @@ -4,12 +4,17 @@ "home": { "title": "FAQ", "createLabel": "FAQ erstellen", + "proposeLabel": "FAQ vorschlagen", + "accept": "FAQ aktzeptieren", + "reject": "FAQ ablehnen", "filterLabel": "Filter", "createOrEditLabel": "FAQ erstellen oder bearbeiten" }, - "created": "Das FAQ wurde erfolgreich erstellt", - "updated": "Das FAQ wurde erfolgreich aktualisiert", - "deleted": "Das FAQ wurde erfolgreich gelöscht", + "created": "Das FAQ mit der ID {{ id }} wurde erfolgreich erstellt", + "updated": "Das FAQ mit der ID {{ id }} wurde erfolgreich aktualisiert", + "deleted": "Das FAQ mit der ID {{ param }} wurde erfolgreich gelöscht", + "accepted": "Das FAQ mit der ID {{ id }} wurde erfolgreich aktzeptiert", + "rejected": "Das FAQ mit der ID {{ id }} wurde erfolgreich abgelehnt", "delete": { "question": "Soll die FAQ {{ title }} wirklich dauerhaft gelöscht werden? Diese Aktion kann NICHT rückgängig gemacht werden!", "typeNameToConfirm": "Bitte gib den Namen des FAQ zur Bestätigung ein." @@ -18,7 +23,8 @@ "table": { "questionTitle": "Fragentitel", "questionAnswer": "Antwort auf die Frage", - "categories": "Kategorien" + "categories": "Kategorien", + "state": "Status" }, "course": "Kurs" } diff --git a/src/main/webapp/i18n/en/faq.json b/src/main/webapp/i18n/en/faq.json index 1a158eb52c40..c736c2602278 100644 --- a/src/main/webapp/i18n/en/faq.json +++ b/src/main/webapp/i18n/en/faq.json @@ -4,12 +4,17 @@ "home": { "title": "FAQ", "createLabel": "Create a new FAQ", + "proposeLabel": "Propose a new FAQ", + "accept": "Accept FAQ", + "reject": "Reject FAQ", "filterLabel": "Filter", "createOrEditLabel": "Create or edit FAQ" }, - "created": "The FAQ was successfully created", - "updated": "The FAQ was successfully updated", - "deleted": "The FAQ was successfully deleted", + "created": "The FAQ with ID {{ id }} was successfully created", + "updated": "The FAQ with ID {{ id }} was successfully updated", + "deleted": "The FAQ with ID {{ param }} was successfully deleted", + "accepted": "The FAQ with ID {{ id }} was successfully accepted", + "rejected": "The FAQ with ID {{ id }} was successfully rejected", "delete": { "question": "Are you sure you want to permanently delete the FAQ {{ title }}? This action can NOT be undone!", "typeNameToConfirm": "Please type in the name of the FAQ to confirm." @@ -18,7 +23,8 @@ "table": { "questionTitle": "Question title", "questionAnswer": "Question answer", - "categories": "Categories" + "categories": "Categories", + "state": "State" }, "course": "Course" }