diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts
index 14da1cbe4cce..a787eeecf13a 100644
--- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts
+++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts
@@ -2,7 +2,7 @@ import { Component, computed, effect, inject, input, signal, untracked } from '@
import { FeedbackAnalysisService, FeedbackChannelRequestDTO, FeedbackDetail } from './feedback-analysis.service';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { AlertService } from 'app/core/util/alert.service';
-import { faFilter, faMessage, faSort, faSortDown, faSortUp, faUsers } from '@fortawesome/free-solid-svg-icons';
+import { faCircleQuestion, faFilter, faMessage, faSort, faSortDown, faSortUp, faSpinner, faUsers } from '@fortawesome/free-solid-svg-icons';
import { facDetails } from '../../../../../../content/icons/icons';
import { SearchResult, SortingOrder } from 'app/shared/table/pageable-table';
import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module';
@@ -54,8 +54,11 @@ export class FeedbackAnalysisComponent {
readonly facDetails = facDetails;
readonly faUsers = faUsers;
readonly faMessage = faMessage;
+ readonly faCircleQuestion = faCircleQuestion;
readonly SortingOrder = SortingOrder;
readonly MAX_FEEDBACK_DETAIL_TEXT_LENGTH = 200;
+ readonly faSpinner = faSpinner;
+ readonly isLoading = signal
(false);
readonly FILTER_TASKS_KEY = 'feedbackAnalysis.tasks';
readonly FILTER_TEST_CASES_KEY = 'feedbackAnalysis.testCases';
@@ -71,6 +74,7 @@ export class FeedbackAnalysisComponent {
private isFeedbackDetailChannelModalOpen = false;
private readonly debounceLoadData = BaseApiHttpService.debounce(this.loadData.bind(this), 300);
+ readonly groupFeedback = signal(false);
constructor() {
effect(() => {
@@ -95,8 +99,9 @@ export class FeedbackAnalysisComponent {
filterErrorCategories: this.errorCategories(),
};
+ this.isLoading.set(true);
try {
- const response = await this.feedbackAnalysisService.search(state, {
+ const response = await this.feedbackAnalysisService.search(state, this.groupFeedback(), {
exerciseId: this.exerciseId(),
filters: {
tasks: this.selectedFiltersCount() !== 0 ? savedTasks : [],
@@ -110,8 +115,11 @@ export class FeedbackAnalysisComponent {
this.taskNames.set(response.taskNames);
this.testCaseNames.set(response.testCaseNames);
this.errorCategories.set(response.errorCategories);
+ this.maxCount.set(response.highestOccurrenceOfGroupedFeedback);
} catch (error) {
this.alertService.error(this.TRANSLATION_BASE + '.error');
+ } finally {
+ this.isLoading.set(false);
}
}
@@ -154,7 +162,11 @@ export class FeedbackAnalysisComponent {
const savedOccurrence = this.localStorage.retrieve(this.FILTER_OCCURRENCE_KEY);
const savedErrorCategories = this.localStorage.retrieve(this.FILTER_ERROR_CATEGORIES_KEY);
this.minCount.set(0);
- this.maxCount.set(await this.feedbackAnalysisService.getMaxCount(this.exerciseId()));
+ if (this.groupFeedback()) {
+ this.maxCount.set(this.maxCount());
+ } else {
+ this.maxCount.set(await this.feedbackAnalysisService.getMaxCount(this.exerciseId()));
+ }
const modalRef = this.modalService.open(FeedbackFilterModalComponent, { centered: true, size: 'lg' });
@@ -198,8 +210,10 @@ export class FeedbackAnalysisComponent {
async openAffectedStudentsModal(feedbackDetail: FeedbackDetail): Promise {
const modalRef = this.modalService.open(AffectedStudentsModalComponent, { centered: true, size: 'lg' });
+ modalRef.componentInstance.courseId = this.courseId;
modalRef.componentInstance.exerciseId = this.exerciseId;
modalRef.componentInstance.feedbackDetail = signal(feedbackDetail);
+ modalRef.componentInstance.groupFeedback = signal(this.groupFeedback());
}
async openFeedbackDetailChannelModal(feedbackDetail: FeedbackDetail): Promise {
@@ -208,13 +222,14 @@ export class FeedbackAnalysisComponent {
}
this.isFeedbackDetailChannelModalOpen = true;
const modalRef = this.modalService.open(FeedbackDetailChannelModalComponent, { centered: true, size: 'lg' });
- modalRef.componentInstance.affectedStudentsCount = await this.feedbackAnalysisService.getAffectedStudentCount(this.exerciseId(), feedbackDetail.detailText);
modalRef.componentInstance.feedbackDetail = signal(feedbackDetail);
+ modalRef.componentInstance.groupFeedback = signal(this.groupFeedback());
modalRef.componentInstance.formSubmitted.subscribe(async ({ channelDto, navigate }: { channelDto: ChannelDTO; navigate: boolean }) => {
try {
const feedbackChannelRequest: FeedbackChannelRequestDTO = {
channel: channelDto,
- feedbackDetailText: feedbackDetail.detailText,
+ feedbackDetailTexts: feedbackDetail.detailTexts,
+ testCaseName: feedbackDetail.testCaseName,
};
const createdChannel = await this.feedbackAnalysisService.createChannel(this.courseId(), this.exerciseId(), feedbackChannelRequest);
const channelName = createdChannel.name;
@@ -237,4 +252,9 @@ export class FeedbackAnalysisComponent {
this.isFeedbackDetailChannelModalOpen = false;
}
}
+
+ toggleGroupFeedback(): void {
+ this.groupFeedback.update((current) => !current);
+ this.loadData();
+ }
}
diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts
index d034cc56a506..db2589719120 100644
--- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts
+++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
-import { PageableResult, PageableSearch, SearchResult, SearchTermPageableSearch } from 'app/shared/table/pageable-table';
+import { SearchResult, SearchTermPageableSearch } from 'app/shared/table/pageable-table';
import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service';
-import { HttpHeaders, HttpParams } from '@angular/common/http';
+import { HttpParams } from '@angular/common/http';
import { FilterData } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component';
import { ChannelDTO } from 'app/entities/metis/conversation/channel.model';
@@ -11,18 +11,18 @@ export interface FeedbackAnalysisResponse {
taskNames: string[];
testCaseNames: string[];
errorCategories: string[];
+ highestOccurrenceOfGroupedFeedback: number;
}
export interface FeedbackDetail {
- concatenatedFeedbackIds: number[];
+ feedbackIds: number[];
count: number;
relativeCount: number;
- detailText: string;
+ detailTexts: string[];
testCaseName: string;
taskName: string;
errorCategory: string;
}
export interface FeedbackAffectedStudentDTO {
- courseId: number;
participationId: number;
firstName: string;
lastName: string;
@@ -31,11 +31,12 @@ export interface FeedbackAffectedStudentDTO {
}
export interface FeedbackChannelRequestDTO {
channel: ChannelDTO;
- feedbackDetailText: string;
+ feedbackDetailTexts: string[];
+ testCaseName: string;
}
@Injectable()
export class FeedbackAnalysisService extends BaseApiHttpService {
- search(pageable: SearchTermPageableSearch, options: { exerciseId: number; filters: FilterData }): Promise {
+ search(pageable: SearchTermPageableSearch, groupFeedback: boolean, options: { exerciseId: number; filters: FilterData }): Promise {
const params = new HttpParams()
.set('page', pageable.page.toString())
.set('pageSize', pageable.pageSize.toString())
@@ -45,7 +46,8 @@ export class FeedbackAnalysisService extends BaseApiHttpService {
.set('filterTasks', options.filters.tasks.join(','))
.set('filterTestCases', options.filters.testCases.join(','))
.set('filterOccurrence', options.filters.occurrence.join(','))
- .set('filterErrorCategories', options.filters.errorCategories.join(','));
+ .set('filterErrorCategories', options.filters.errorCategories.join(','))
+ .set('groupFeedback', groupFeedback.toString());
return this.get(`exercises/${options.exerciseId}/feedback-details`, { params });
}
@@ -54,26 +56,18 @@ export class FeedbackAnalysisService extends BaseApiHttpService {
return this.get(`exercises/${exerciseId}/feedback-details-max-count`);
}
- async getParticipationForFeedbackIds(exerciseId: number, feedbackIds: number[], pageable: PageableSearch): Promise> {
- const feedbackIdsHeader = feedbackIds.join(',');
-
- const params = new HttpParams()
- .set('page', pageable.page.toString())
- .set('pageSize', pageable.pageSize.toString())
- .set('sortedColumn', pageable.sortedColumn)
- .set('sortingOrder', pageable.sortingOrder);
+ async getParticipationForFeedbackDetailText(exerciseId: number, feedbackIds: number[]): Promise {
+ let params = new HttpParams();
+ const topFeedbackIds = feedbackIds.slice(0, 5);
- const headers = new HttpHeaders().set('feedbackIds', feedbackIdsHeader);
+ topFeedbackIds.forEach((id, index) => {
+ params = params.set(`feedbackId${index + 1}`, id.toString());
+ });
- return this.get>(`exercises/${exerciseId}/feedback-details-participation`, { params, headers });
+ return this.get(`exercises/${exerciseId}/feedback-details-participation`, { params });
}
createChannel(courseId: number, exerciseId: number, feedbackChannelRequest: FeedbackChannelRequestDTO): Promise {
return this.post(`courses/${courseId}/${exerciseId}/feedback-channel`, feedbackChannelRequest);
}
-
- getAffectedStudentCount(exerciseId: number, feedbackDetailText: string): Promise {
- const params = new HttpParams().set('detailText', feedbackDetailText);
- return this.get(`exercises/${exerciseId}/feedback-detail/affected-students`, { params });
- }
}
diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.html b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.html
index 4c6052b563d4..603fae72e0a4 100644
--- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.html
+++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.html
@@ -5,10 +5,24 @@
}
@if (isProgrammingOrTextExercise) {
-
+
}
}
@if (plagiarismComparison) {
@if (isModelingExercise) {
@@ -20,10 +34,14 @@
}
@if (isProgrammingOrTextExercise) {
}
}
diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.scss b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.scss
index 4956ee867320..12ac2a9ad4a2 100644
--- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.scss
+++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.scss
@@ -19,3 +19,7 @@
position: relative;
}
}
+
+.split-pane-header-color {
+ background-color: var(--bs-body-bg);
+}
diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.ts b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.ts
index 340d72154ce6..c8e6fe103fa5 100644
--- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.ts
+++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.ts
@@ -1,4 +1,4 @@
-import { AfterViewInit, Component, Directive, ElementRef, Input, OnChanges, OnInit, QueryList, SimpleChanges, ViewChildren } from '@angular/core';
+import { AfterViewInit, Component, Directive, ElementRef, Input, OnChanges, OnDestroy, OnInit, QueryList, SimpleChanges, ViewChildren } from '@angular/core';
import * as Split from 'split.js';
import { Subject } from 'rxjs';
import { PlagiarismComparison } from 'app/exercises/shared/plagiarism/types/PlagiarismComparison';
@@ -10,6 +10,8 @@ import { PlagiarismCasesService } from 'app/course/plagiarism-cases/shared/plagi
import { HttpResponse } from '@angular/common/http';
import { SimpleMatch } from 'app/exercises/shared/plagiarism/types/PlagiarismMatch';
import dayjs from 'dayjs/esm';
+import { TextPlagiarismFileElement } from 'app/exercises/shared/plagiarism/types/text/TextPlagiarismFileElement';
+import { IconDefinition, faLock, faUnlock } from '@fortawesome/free-solid-svg-icons';
@Directive({ selector: '[jhiPane]' })
export class SplitPaneDirective {
@@ -21,7 +23,7 @@ export class SplitPaneDirective {
styleUrls: ['./plagiarism-split-view.component.scss'],
templateUrl: './plagiarism-split-view.component.html',
})
-export class PlagiarismSplitViewComponent implements AfterViewInit, OnChanges, OnInit {
+export class PlagiarismSplitViewComponent implements AfterViewInit, OnChanges, OnInit, OnDestroy {
@Input() comparison: PlagiarismComparison
;
@Input() exercise: Exercise;
@Input() splitControlSubject: Subject;
@@ -31,6 +33,9 @@ export class PlagiarismSplitViewComponent implements AfterViewInit, OnChanges, O
@ViewChildren(SplitPaneDirective) panes!: QueryList;
plagiarismComparison: PlagiarismComparison;
+ fileSelectedSubject = new Subject();
+ showFilesSubject = new Subject();
+ dropdownHoverSubject = new Subject();
public split: Split.Instance;
@@ -39,13 +44,16 @@ export class PlagiarismSplitViewComponent implements AfterViewInit, OnChanges, O
public matchesA: Map;
public matchesB: Map;
+ isLockFilesEnabled = false;
readonly dayjs = dayjs;
+ protected readonly faLock: IconDefinition = faLock;
+ protected readonly faUnlock: IconDefinition = faUnlock;
constructor(private plagiarismCasesService: PlagiarismCasesService) {}
/**
- * Initialize third party libs inside this lifecycle hook.
+ * Initialize third-party libraries inside this lifecycle hook.
*/
ngAfterViewInit(): void {
const paneElements = this.panes.map((pane: SplitPaneDirective) => pane.elementRef.nativeElement);
@@ -84,6 +92,12 @@ export class PlagiarismSplitViewComponent implements AfterViewInit, OnChanges, O
}
}
+ ngOnDestroy() {
+ this.fileSelectedSubject.complete();
+ this.showFilesSubject.complete();
+ this.dropdownHoverSubject.complete();
+ }
+
/**
* Swaps fields of A with fields of B in-place.
* More specifically, swaps submissionA with submissionB and startA with startB in matches.
@@ -177,4 +191,11 @@ export class PlagiarismSplitViewComponent implements AfterViewInit, OnChanges, O
}
}
}
+
+ /**
+ * Toggles the state of file locking and emits the new state to the parent component.
+ */
+ toggleLockFiles() {
+ this.isLockFilesEnabled = !this.isLockFilesEnabled;
+ }
}
diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.html b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.html
index 865d3dfe654c..556a5e8bf3bf 100644
--- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.html
+++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.html
@@ -1,5 +1,5 @@
}
diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.html b/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.html
new file mode 100644
index 000000000000..ef9949c49798
--- /dev/null
+++ b/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.html
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+ @if (sshFingerprints && sshFingerprints['RSA']) {
+
+
+ {{ 'RSA' }}
+
+
+ {{ sshFingerprints['RSA'] }}
+
+
+ }
+
+ @if (sshFingerprints && sshFingerprints['EdDSA']) {
+
+
+ {{ 'ED25519' }}
+
+
+ {{ sshFingerprints['EdDSA'] }}
+
+
+ }
+
+ @if (sshFingerprints && sshFingerprints['ECDSA']) {
+
+
+ {{ 'ECDSA' }}
+
+
+ {{ sshFingerprints['ECDSA'] }}
+
+
+ }
+
+ @if (sshFingerprints && sshFingerprints['EC']) {
+
+
+ {{ 'ECDSA' }}
+
+
+ {{ sshFingerprints['EC'] }}
+
+
+ }
+
+
+
+
diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.scss b/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.scss
new file mode 100644
index 000000000000..61b3ac821994
--- /dev/null
+++ b/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.scss
@@ -0,0 +1,12 @@
+.column {
+ float: left;
+ padding: 10px;
+}
+
+.left {
+ width: 15%;
+}
+
+.right {
+ width: 85%;
+}
diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.ts b/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.ts
new file mode 100644
index 000000000000..52e8676bb456
--- /dev/null
+++ b/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.ts
@@ -0,0 +1,24 @@
+import { Component, OnInit, inject } from '@angular/core';
+import { ButtonSize, ButtonType } from 'app/shared/components/button.component';
+import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component';
+import { SshUserSettingsFingerprintsService } from 'app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.service';
+
+@Component({
+ selector: 'jhi-account-information',
+ templateUrl: './ssh-user-settings-fingerprints.component.html',
+ styleUrls: ['./ssh-user-settings-fingerprints.component.scss', '../ssh-user-settings.component.scss'],
+})
+export class SshUserSettingsFingerprintsComponent implements OnInit {
+ readonly sshUserSettingsService = inject(SshUserSettingsFingerprintsService);
+
+ protected sshFingerprints?: { [key: string]: string };
+
+ readonly documentationType: DocumentationType = 'SshSetup';
+ protected readonly ButtonType = ButtonType;
+
+ protected readonly ButtonSize = ButtonSize;
+
+ async ngOnInit() {
+ this.sshFingerprints = await this.sshUserSettingsService.getSshFingerprints();
+ }
+}
diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.service.ts b/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.service.ts
new file mode 100644
index 000000000000..ac14ef15666d
--- /dev/null
+++ b/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.service.ts
@@ -0,0 +1,14 @@
+import { Injectable, inject } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { firstValueFrom } from 'rxjs';
+
+@Injectable({ providedIn: 'root' })
+export class SshUserSettingsFingerprintsService {
+ error?: string;
+
+ private http = inject(HttpClient);
+
+ public async getSshFingerprints(): Promise<{ [key: string]: string }> {
+ return await firstValueFrom(this.http.get<{ [key: string]: string }>('api/ssh-fingerprints'));
+ }
+}
diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html
index a5650cef05a9..a0db34d38aa7 100644
--- a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html
+++ b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html
@@ -17,7 +17,7 @@